# Conflicts:
#	rsconcept/frontend/src/App.tsx
This commit is contained in:
Ulle9 2023-08-10 16:20:35 +03:00
commit 1f06f01645
65 changed files with 1047 additions and 528 deletions

View File

@ -59,4 +59,5 @@ bower_components
# Specific items
docker-compose.yml
docker-compose-dev.yml
docker-compose-prod.yml

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
# SECURITY SENSITIVE FILES
# persistent/*
secrets/
cert/
# External distributions
rsconcept/backend/import/*.whl

20
.vscode/launch.json vendored
View File

@ -52,6 +52,26 @@
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1",
"args": ["-freshStart"]
},
{
"name": "FE-Debug",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/rsconcept/frontend/node_modules/.bin/jest",
"args": [
"${fileBasenameNoExtension}",
"--runInBand",
"--watch",
"--coverage=false",
"--no-cache"
],
"cwd": "${workspaceFolder}/rsconcept/frontend",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"sourceMaps": true,
"windows": {
"program": "${workspaceFolder}/rsconcept/frontend/node_modules/jest/bin/jest"
}
},
]
}

View File

@ -9,6 +9,8 @@ This readme file is used mostly to document project dependencies
- run rsconcept\backend\LocalEnvSetup.ps1
- run 'npm install' in rsconcept\frontend
- use VSCode configs in root folder to start developement
- production: create secrets secrets\db_password.txt and django_key.txt
- production: provide TLS certificate nginx\cert\portal-cert.pem and nginx\cert\portal-key.pem
# Frontend stack & Tooling [Vite + React + Typescript]
<details>

View File

@ -2,6 +2,23 @@
This list only contains global tech refactorings and tech debt
For more specific TODOs see comments in code
[Functionality]
- home page
- manuals
- текстовый модуль для разрешения отсылок
- компонент для форматирования в редакторе текста (формальное выражения + отсылки в тексте)
- блок нотификаций пользователей
- блок синтеза
- блок организации библиотеки моделей
- проектный модуль?
- обратная связь - система баг репортов
[Tech]
- Use migtation/fixtures to provide initial data for testing
- USe migtation/fixtures to load example common data
- Add HTTPS for deployment
[deployment]
- HTTPS
- database backup daemon
- logs collection
- status dashboard for servers

View File

@ -1,8 +1,6 @@
version: "3.9"
volumes:
postgres_volume:
name: "postgres-db"
name: "postgresql-db"
django_static_volume:
name: "static"
django_media_volume:
@ -12,6 +10,12 @@ networks:
default:
name: concept-api-net
secrets:
django_key:
file: ./secrets/django_key.txt
db_password:
file: ./secrets/db_password.txt
services:
frontend:
restart: always
@ -19,8 +23,8 @@ services:
- backend
build:
context: ./rsconcept/frontend
ports:
- 3000:3000
expose:
- 3000
command: serve -s /home/node -l 3000
@ -28,12 +32,17 @@ services:
restart: always
depends_on:
- postgresql-db
- nginx
secrets:
- db_password
- django_key
build:
context: ./rsconcept/backend
env_file: ./rsconcept/backend/.env.dev
ports:
- 8000:8000
env_file: ./rsconcept/backend/.env.prod
environment:
SECRET_KEY: /run/secrets/django_key
DB_PASSWORD: /run/secrets/db_password
expose:
- 8000
volumes:
- django_static_volume:/home/app/web/static
- django_media_volume:/home/app/web/media
@ -44,7 +53,11 @@ services:
postgresql-db:
restart: always
image: postgres:alpine
env_file: ./postgresql/.env.dev
secrets:
- db_password
env_file: ./postgresql/.env.prod
environment:
POSTGRES_PASSWORD: /run/secrets/db_password
volumes:
- postgres_volume:/var/lib/postgresql/data
@ -54,9 +67,11 @@ services:
build:
context: ./nginx
ports:
- 1337:80
- 8000:8000
- 3000:3000
depends_on:
- backend
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
volumes:
- django_static_volume:/var/www/static
- django_media_volume:/var/www/media

View File

@ -2,3 +2,4 @@ FROM nginx:stable-alpine3.17-slim
# Сopу nginx configuration to the proxy-server
COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./cert/* /etc/ssl/private/

View File

@ -1,12 +1,17 @@
upstream innerdjango {
server backend:8000;
# `backend` is the service's name in docker-compose.yml,
# The `innerdjango` is the name of upstream, used by nginx below.
}
upstream innerreact {
server frontend:3000;
}
server {
listen 80;
server_name rs.acconcept.ru;
listen 8000 ssl;
ssl_certificate /etc/ssl/private/portal-cert.pem;
ssl_certificate_key /etc/ssl/private/portal-key.pem;
server_name dev.concept.ru www.dev.concept.ru api.portal.acconcept.ru www.api.portal.acconcept.ru;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
@ -20,3 +25,17 @@ server {
alias /var/www/media/;
}
}
server {
listen 3000 ssl;
ssl_certificate /etc/ssl/private/portal-cert.pem;
ssl_certificate_key /etc/ssl/private/portal-key.pem;
server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://innerreact;
proxy_redirect default;
}
}

View File

@ -1,3 +0,0 @@
POSTGRES_USER=dev-test-user
POSTGRES_PASSWORD=02BD82EE0D
POSTGRES_DB=dev-db

2
postgresql/.env.prod Normal file
View File

@ -0,0 +1,2 @@
POSTGRES_USER=portal-admin
POSTGRES_DB=portal-db

View File

@ -1,25 +0,0 @@
# Application settings
SECRET_KEY=django-insecure-)rq@!&v7l2r%2%q#n!uq+zk@=&yc0^&ql^7%2!%9u)vt1x&j=d
ALLOWED_HOSTS=rs.acconcept.ru;localhost
CSRF_TRUSTED_ORIGINS=http://rs.acconcept.ru:3000;localhost:3000
CORS_ALLOWED_ORIGINS=http://rs.acconcept.ru:3000;localhost:3000
# File locations
STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Database settings
DB_ENGINE=django.db.backends.postgresql_psycopg2
DB_NAME=dev-db
DB_USER=dev-test-user
DB_PASSWORD=02BD82EE0D
DB_HOST=postgresql-db
DB_PORT=5432
# Debug settings
DEBUG=1
PYTHONDEVMODE=1
PYTHONTRACEMALLOC=1

View File

@ -0,0 +1,24 @@
# Application settings
ALLOWED_HOSTS=localhost;portal.acconcept.ru;dev.concept.ru
CSRF_TRUSTED_ORIGINS=https://dev.concept.ru:3000;https://localhost:3000;https://portal.acconcept.ru:3000
CORS_ALLOWED_ORIGINS=https://dev.concept.ru:3000;https://localhost:3000;https://portal.acconcept.ru:3000
# File locations
STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Database settings
DB_ENGINE=django.db.backends.postgresql_psycopg2
DB_NAME=portal-db
DB_USER=portal-admin
DB_HOST=postgresql-db
DB_PORT=5432
# Debug settings
DEBUG=0
PYTHONDEVMODE=0
PYTHONTRACEMALLOC=0

View File

@ -55,8 +55,9 @@ RUN pip install --no-cache /wheels/* && \
rm -rf /wheels
# Copy application sources and setup permissions
COPY apps/ ./apps/
COPY apps/ ./apps
COPY project/ ./project
COPY data/ ./data
COPY manage.py entrypoint.sh ./
RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \
chmod +x $APP_HOME/entrypoint.sh && \
@ -64,6 +65,8 @@ RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \
chmod -R a+rwx $APP_HOME/static && \
chmod -R a+rwx $APP_HOME/media
RUN
USER app
WORKDIR $APP_HOME

View File

@ -43,9 +43,15 @@ class ConstituentaSerializer(serializers.ModelSerializer):
fields = '__all__'
read_only_fields = ('id', 'order', 'alias', 'cst_type')
def update(self, instance: Constituenta, validated_data):
def update(self, instance: Constituenta, validated_data) -> Constituenta:
if ('term_raw' in validated_data):
validated_data['term_resolved'] = validated_data['term_raw']
if ('definition_raw' in validated_data):
validated_data['definition_resolved'] = validated_data['definition_raw']
result = super().update(instance, validated_data)
instance.schema.save()
return super().update(instance, validated_data)
return result
class StandaloneCstSerializer(serializers.ModelSerializer):

View File

@ -34,6 +34,7 @@ class TestIntegrations(TestCase):
schema = self._default_schema()
out1 = json.loads(pc.check_expression(schema, 'X1=X1'))
self.assertTrue(out1['parseResult'])
self.assertEqual(len(out1['args']), 0)
out2 = json.loads(pc.check_expression(schema, 'X1=X2'))
self.assertFalse(out2['parseResult'])

View File

@ -31,6 +31,10 @@ class TestConstituentaAPI(APITestCase):
alias='X1', schema=self.rsform_owned, order=1, convention='Test')
self.cst2 = Constituenta.objects.create(
alias='X2', schema=self.rsform_unowned, order=1, convention='Test1')
self.cst3 = Constituenta.objects.create(
alias='X3', schema=self.rsform_owned, order=2,
term_raw='Test1', term_resolved='Test1',
definition_raw='Test1', definition_resolved='Test2')
def test_retrieve(self):
response = self.client.get(f'/api/constituents/{self.cst1.id}/')
@ -57,6 +61,17 @@ class TestConstituentaAPI(APITestCase):
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
def test_partial_update_update_resolved(self):
data = json.dumps({
'term_raw': 'New term',
'definition_raw': 'New def'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(self.cst3.definition_resolved, 'New def')
def test_readonly_cst_fields(self):
data = json.dumps({'alias': 'X33', 'order': 10})
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')

View File

@ -11,6 +11,7 @@ then
echo "Ready!"
fi
cd $APP_HOME
python $APP_HOME/manage.py collectstatic --noinput --clear
python $APP_HOME/manage.py migrate

View File

@ -82,9 +82,9 @@ MIDDLEWARE = [
]
ROOT_URLCONF = 'project.urls'
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/home'
LOGOUT_REDIRECT_URL = '/home'
LOGIN_URL = '/admin/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
TEMPLATES = [
{

View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -11,6 +11,7 @@ WORKDIR /result
COPY ./ ./
RUN npm install
ENV NODE_ENV production
RUN npm run build
# ========= Server =======

View File

@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"axios": "^1.4.0",
"js-file-download": "^0.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "jest",

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Route, Routes } from 'react-router-dom';
import Footer from './components/Footer';
@ -16,9 +17,21 @@ import RSFormPage from './pages/RSFormPage';
import UserProfilePage from './pages/UserProfilePage';
function App () {
const {noNavigation} = useConceptTheme()
const nav_height: string = noNavigation ? "7.5rem" : "7.4rem";
const main_clsN: string = `min-h-[calc(100vh-${nav_height})] px-2 h-fit`;
const { noNavigation } = useConceptTheme();
const scrollWindowSize = useMemo(
() => {
return !noNavigation ?
'max-h-[calc(100vh-4.5rem)]'
: 'max-h-[100vh]';
}, [noNavigation]);
const mainSize = useMemo(
() => {
return !noNavigation ?
'min-h-[calc(100vh-12rem)]'
: 'min-h-[calc(100vh-8rem)] ';
}, [noNavigation]);
return (
<div className='antialiased clr-app'>
<Navigation />
@ -28,7 +41,9 @@ function App () {
draggable={false}
pauseOnFocusLoss={false}
/>
<main className={main_clsN}>
<div className={`${scrollWindowSize} overflow-auto`}>
<main className={`${mainSize} px-2`}>
<Routes>
<Route path='/' element={ <HomePage/>} />
@ -47,6 +62,7 @@ function App () {
</main>
<Footer />
</div>
</div>
);
}

View File

@ -13,7 +13,9 @@ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'child
function Button({
id, text, icon, tooltip,
dense, disabled,
borderClass = 'border rounded', colorClass = 'clr-btn-default', widthClass = 'w-fit h-fit',
borderClass = 'border rounded',
colorClass = 'clr-btn-default',
widthClass = 'w-fit h-fit',
loading, onClick,
...props
}: ButtonProps) {

View File

@ -6,14 +6,15 @@ import Button from './Button';
interface ModalProps {
title?: string
submitText?: string
readonly?: boolean
canSubmit?: boolean
hideWindow: () => void
onSubmit: () => void
onSubmit?: () => void
onCancel?: () => void
children: React.ReactNode
}
function Modal({ title, hideWindow, onSubmit, onCancel, canSubmit, children, submitText = 'Продолжить' }: ModalProps) {
function Modal({ title, hideWindow, onSubmit, readonly, onCancel, canSubmit, children, submitText = 'Продолжить' }: ModalProps) {
const ref = useRef(null);
useEscapeKey(hideWindow);
@ -24,29 +25,32 @@ function Modal({ title, hideWindow, onSubmit, onCancel, canSubmit, children, sub
const handleSubmit = () => {
hideWindow();
onSubmit();
if (onSubmit) onSubmit();
};
return (
<>
<div className='fixed top-0 left-0 z-50 w-full h-full opacity-50 clr-modal'>
</div>
<div ref={ref} className='fixed bottom-1/2 left-1/2 translate-y-1/2 -translate-x-1/2 px-6 py-4 flex flex-col w-fit h-fit z-[60] clr-card border shadow-md mb-[5rem]'>
<div
ref={ref}
className='fixed bottom-1/2 left-1/2 translate-y-1/2 -translate-x-1/2 px-6 py-4 flex flex-col w-fit max-w-[95vw] h-fit z-[60] clr-card border shadow-md mb-[5rem]'
>
{ title && <h1 className='mb-2 text-xl font-bold text-center'>{title}</h1> }
<div>
<div className='max-h-[calc(95vh-15rem)] overflow-auto'>
{children}
</div>
<div className='flex justify-between w-full pt-4 mt-2 border-t-4'>
<Button
<div className='flex justify-center w-full gap-4 pt-4 mt-2 border-t-4'>
{!readonly && <Button
text={submitText}
widthClass='min-w-[6rem] min-h-[2.6rem] w-fit h-fit'
colorClass='clr-btn-primary'
disabled={!canSubmit}
onClick={handleSubmit}
autoFocus
/>
/>}
<Button
text='Отмена'
text={readonly ? 'Закрыть' : 'Отмена'}
widthClass='min-w-[6rem] min-h-[2.6rem] w-fit h-fit'
onClick={handleCancel}
/>

View File

@ -8,7 +8,7 @@ interface SubmitButtonProps {
function SubmitButton({ text = 'ОК', icon, disabled, loading = false }: SubmitButtonProps) {
return (
<button type='submit'
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold disabled:cursor-not-allowed rounded clr-btn-primary ${loading ? ' cursor-progress' : ''}`}
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold disabled:cursor-not-allowed border rounded clr-btn-primary ${loading ? ' cursor-progress' : ''}`}
disabled={disabled ?? loading}
>
{icon && <span>{icon}</span>}

View File

@ -1,24 +1,20 @@
import { TextareaHTMLAttributes } from 'react';
import Label from './Label';
interface TextAreaProps {
id: string
interface TextAreaProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
label: string
required?: boolean
disabled?: boolean
spellCheck?: boolean
placeholder?: string
widthClass?: string
rows?: number
value?: string | ReadonlyArray<string> | number | undefined;
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void
onFocus?: () => void
colorClass?: string
}
function TextArea({
id, label, placeholder,
required, spellCheck, disabled,
widthClass = 'w-full', rows = 4, value,
onChange, onFocus
id, label, required,
widthClass = 'w-full',
colorClass = 'colorClass',
rows = 4,
...props
}: TextAreaProps) {
return (
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3'>
@ -28,15 +24,10 @@ function TextArea({
htmlFor={id}
/>
<textarea id={id}
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 ' + widthClass}
className={`px-3 py-2 mt-2 leading-tight border shadow ${colorClass} ${widthClass}`}
rows={rows}
placeholder={placeholder}
required={required}
value={value}
onChange={onChange}
onFocus={onFocus}
disabled={disabled}
spellCheck={spellCheck}
{...props}
/>
</div>
);

View File

@ -7,10 +7,13 @@ interface TextInputProps
id: string
label: string
widthClass?: string
colorClass?: string
}
function TextInput({
id, required, label, widthClass = 'w-full',
id, required, label,
widthClass = 'w-full',
colorClass = 'clr-input',
...props
}: TextInputProps) {
return (
@ -21,7 +24,7 @@ function TextInput({
htmlFor={id}
/>
<input id={id}
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 truncate hover:text-clip ' + widthClass}
className={`px-3 py-2 mt-2 leading-tight border shadow truncate hover:text-clip ${colorClass} ${widthClass}`}
required={required}
{...props}
/>

View File

@ -1,14 +1,19 @@
import { Link } from 'react-router-dom';
import { urls } from '../utils/constants';
import { GithubIcon } from './Icons';
function Footer() {
return (
<footer className='z-50 px-4 pt-2 pb-4 border-t-2 t clr-footer'>
<footer className='z-50 px-4 pt-2 pb-4 border-t-2 clr-footer'>
<div className='flex items-stretch justify-center w-full mx-auto'>
<div className='px-4 underline'>
<div className='px-4 underline whitespace-nowrap'>
<Link to='/manuals' tabIndex={-1}>Справка</Link> <br/>
<Link to='/library?filter=common' tabIndex={-1}>Библиотека КС</Link> <br/>
<a href={urls.gitrepo} className='flex'>
<GithubIcon />
<span className='ml-1'>Репозиторий</span>
</a>
</div>
<div className='px-4 underline border-gray-400 border-x dark:border-gray-300'>
<ul>
@ -23,9 +28,9 @@ function Footer() {
</li>
</ul>
</div>
<div className='max-w-xl px-4 text-sm'>
<div className='max-w-[28rem] px-4 text-sm'>
<p className='mt-0.5'>© 2023 ЦИВТ КОНЦЕПТ</p>
<p>Данный инструмент работы с экспликациями концептуальных схем в родоструктурной форме является уникальной Российской разработкой и вобрал в себя разработки начиная с 1990-х годов</p>
<p>Портал позволяет анализировать предметные области, формально записывать системы определений и синтезировать их с помощью математического аппарата родов структур</p>
</div>
</div>
</footer >

View File

@ -292,3 +292,11 @@ export function HelpIcon(props: IconProps) {
</IconSVG>
);
}
export function GithubIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 24 24' {...props}>
<path d='M12 2.247a10 10 0 00-3.162 19.487c.5.088.687-.212.687-.475 0-.237-.012-1.025-.012-1.862-2.513.462-3.163-.613-3.363-1.175a3.636 3.636 0 00-1.025-1.413c-.35-.187-.85-.65-.013-.662a2.001 2.001 0 011.538 1.025 2.137 2.137 0 002.912.825 2.104 2.104 0 01.638-1.338c-2.225-.25-4.55-1.112-4.55-4.937a3.892 3.892 0 011.025-2.688 3.594 3.594 0 01.1-2.65s.837-.262 2.75 1.025a9.427 9.427 0 015 0c1.912-1.3 2.75-1.025 2.75-1.025a3.593 3.593 0 01.1 2.65 3.869 3.869 0 011.025 2.688c0 3.837-2.338 4.687-4.563 4.937a2.368 2.368 0 01.675 1.85c0 1.338-.012 2.413-.012 2.75 0 .263.187.575.687.475A10.005 10.005 0 0012 2.247z' />
</IconSVG>
);
}

View File

@ -1,11 +0,0 @@
interface InfoMessageProps {
message: string
}
export function InfoMessage({ message }: InfoMessageProps) {
return (
<p className='font-bold'>{ message }</p>
);
}
export default InfoMessage;

View File

@ -18,7 +18,7 @@ function Navigation () {
const navigateHelp = () => { navigate('/manuals') };
return (
<nav className='sticky top-0 left-0 right-0 z-50'>
<nav className='sticky top-0 left-0 right-0 z-50 h-fit'>
{!noNavigation &&
<button
title='Скрыть навигацию'

View File

@ -1,11 +1,13 @@
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { useAuth } from '../../context/AuthContext';
import { BellIcon, PlusIcon, SquaresIcon } from '../Icons';
import NavigationButton from './NavigationButton';
function UserTools() {
const navigate = useNavigate();
const { user } = useAuth();
const navigateCreateRSForm = () => { navigate('/rsform-create'); };
const navigateMyWork = () => { navigate('/library?filter=personal'); };
@ -24,7 +26,7 @@ function UserTools() {
/>
</span>
<NavigationButton icon={<SquaresIcon />} description='Мои схемы' onClick={navigateMyWork} />
<NavigationButton icon={<BellIcon />} description='Уведомления' onClick={handleNotifications} />
{ user && user.is_staff && <NavigationButton icon={<BellIcon />} description='Уведомления' onClick={handleNotifications} />}
</div>
);
}

View File

@ -1,6 +1,5 @@
import { useAuth } from '../context/AuthContext';
import TextURL from './Common/TextURL';
import InfoMessage from './InfoMessage';
interface RequireAuthProps {
children: React.ReactNode
@ -13,12 +12,12 @@ function RequireAuth({ children }: RequireAuthProps) {
<>
{user && children}
{!user &&
<div className='flex flex-col items-center'>
<InfoMessage message={'Данная функция доступна только зарегистрированным пользователям. Пожалуйста войдите в систему'} />
<div className='flex flex-col items-start'>
<TextURL text='Войти в систему...' href='login' />
<TextURL text='Зарегистрироваться...' href='signup' />
</div>
<div className='flex flex-col items-center mt-2 gap-1'>
<p><b>Данная страница доступна только зарегистрированным пользователям</b></p>
<p className='mb-2'>Пожалуйста войдите в систему</p>
<TextURL text='Войти в систему' href='/login'/>
<TextURL text='Зарегистрироваться' href='/signup'/>
<TextURL text='Начальная страница' href='/'/>
</div>
}
</>

View File

@ -10,6 +10,7 @@ interface IAuthContext {
login: (data: IUserLoginData, callback?: DataCallback) => void
logout: (callback?: DataCallback) => void
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void
reload: (callback?: () => void) => void
loading: boolean
error: ErrorInfo
setError: (error: ErrorInfo) => void
@ -48,8 +49,7 @@ export const AuthState = ({ children }: AuthStateProps) => {
if (callback) callback();
}
});
}, [setUser]
);
}, [setUser]);
function login(data: IUserLoginData, callback?: DataCallback) {
setError(undefined);
@ -87,7 +87,7 @@ export const AuthState = ({ children }: AuthStateProps) => {
return (
<AuthContext.Provider
value={{ user, login, logout, signup, loading, error, setError }}
value={{ user, login, logout, signup, loading, error, reload, setError }}
>
{children}
</AuthContext.Provider>

View File

@ -1,17 +1,19 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { ErrorInfo } from '../components/BackendError';
import { getLibrary } from '../utils/backendAPI';
import { ILibraryFilter, IRSFormMeta, matchRSFormMeta } from '../utils/models';
import { DataCallback, getLibrary, postNewRSForm } from '../utils/backendAPI';
import { ILibraryFilter, IRSFormCreateData, IRSFormMeta, matchRSFormMeta } from '../utils/models';
interface ILibraryContext {
items: IRSFormMeta[]
loading: boolean
processing: boolean
error: ErrorInfo
setError: (error: ErrorInfo) => void
reload: () => void
filter: (params: ILibraryFilter) => IRSFormMeta[]
createSchema: (data: IRSFormCreateData, callback?: DataCallback<IRSFormMeta>) => void
}
const LibraryContext = createContext<ILibraryContext | null>(null)
@ -32,6 +34,7 @@ interface LibraryStateProps {
export const LibraryState = ({ children }: LibraryStateProps) => {
const [ items, setItems ] = useState<IRSFormMeta[]>([])
const [ loading, setLoading ] = useState(false);
const [ processing, setProcessing ] = useState(false);
const [ error, setError ] = useState<ErrorInfo>(undefined);
const filter = useCallback(
@ -63,12 +66,27 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
useEffect(() => {
reload();
}, [reload])
}, [reload]);
const createSchema = useCallback(
(data: IRSFormCreateData, callback?: DataCallback<IRSFormMeta>) => {
setError(undefined);
postNewRSForm({
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error); },
onSuccess: newSchema => {
reload();
if (callback) callback(newSchema);
}
});
}, [reload]);
return (
<LibraryContext.Provider value={{
items, loading, error, setError,
reload, filter
items, loading, processing, error, setError,
reload, filter, createSchema
}}>
{ children }
</LibraryContext.Provider>

View File

@ -5,14 +5,17 @@ import { type ErrorInfo } from '../components/BackendError'
import { useRSFormDetails } from '../hooks/useRSFormDetails'
import {
type DataCallback, deleteRSForm, getTRSFile,
patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchResetAliases,
patchRSForm,
patchUploadTRS, postClaimRSForm, postCloneRSForm,postNewConstituenta} from '../utils/backendAPI'
patchConstituenta, patchDeleteConstituenta,
patchMoveConstituenta, patchResetAliases, patchRSForm,
patchUploadTRS, postClaimRSForm, postCloneRSForm, postNewConstituenta
} from '../utils/backendAPI'
import {
IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData, IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData,
IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
} from '../utils/models'
import { useAuth } from './AuthContext'
import { useLibrary } from './LibraryContext'
interface IRSFormContext {
schema?: IRSForm
@ -63,15 +66,16 @@ interface RSFormStateProps {
}
export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const { user } = useAuth()
const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID })
const [processing, setProcessing] = useState(false)
const { user } = useAuth();
const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID });
const [ processing, setProcessing ] = useState(false);
const library = useLibrary();
const [isForceAdmin, setIsForceAdmin] = useState(false)
const [isReadonly, setIsReadonly] = useState(false)
const [ isForceAdmin, setIsForceAdmin ] = useState(false);
const [ isReadonly, setIsReadonly ] = useState(false);
const isOwned = useMemo(() => user?.id === schema?.owner || false, [user, schema?.owner])
const isClaimable = useMemo(() => user?.id !== schema?.owner || false, [user, schema?.owner])
const isOwned = useMemo(() => user?.id === schema?.owner || false, [user, schema?.owner]);
const isClaimable = useMemo(() => user?.id !== schema?.owner || false, [user, schema?.owner]);
const isEditable = useMemo(
() => {
return (
@ -83,12 +87,12 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const isTracking = useMemo(
() => {
return true
}, [])
}, []);
const toggleTracking = useCallback(
() => {
toast('not implemented yet')
}, [])
toast.info('Отслеживание в разработке...')
}, []);
const update = useCallback(
(data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => {
@ -100,13 +104,13 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onError: error => setError(error),
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
}
});
}, [schemaID, setError, setSchema, schema])
}, [schemaID, setError, setSchema, schema]);
const upload = useCallback(
(data: IRSFormUploadData, callback?: () => void) => {
@ -118,13 +122,13 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onError: error => setError(error),
onSuccess: newData => {
setSchema(newData);
if (callback) callback();
}
});
}, [schemaID, setError, setSchema, schema])
}, [schemaID, setError, setSchema, schema]);
const destroy = useCallback(
(callback?: () => void) => {
@ -132,13 +136,14 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
deleteRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onError: error => setError(error),
onSuccess: () => {
setSchema(undefined);
library.reload();
if (callback) callback();
}
});
}, [schemaID, setError, setSchema])
}, [schemaID, setError, setSchema, library]);
const claim = useCallback(
(callback?: DataCallback<IRSFormMeta>) => {
@ -149,13 +154,13 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
postClaimRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onError: error => setError(error),
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
}
});
}, [schemaID, setError, schema, user, setSchema])
}, [schemaID, setError, schema, user, setSchema]);
const clone = useCallback(
(data: IRSFormCreateData, callback: DataCallback<IRSFormData>) => {
@ -167,10 +172,13 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: callback
onError: error => setError(error),
onSuccess: newSchema => {
library.reload();
if (callback) callback(newSchema);
}
});
}, [schemaID, setError, schema, user])
}, [schemaID, setError, schema, user, library]);
const resetAliases = useCallback(
(callback?: () => void) => {
@ -187,7 +195,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
if (callback) callback();
}
});
}, [schemaID, setError, schema, user, setSchema])
}, [schemaID, setError, schema, user, setSchema]);
const download = useCallback(
(callback: DataCallback<Blob>) => {
@ -198,7 +206,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
onError: error => { setError(error) },
onSuccess: callback
});
}, [schemaID, setError])
}, [schemaID, setError]);
const cstCreate = useCallback(
(data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => {
@ -242,7 +250,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
reload(setProcessing, () => { if (callback != null) callback(newData); })
}
});
}, [setError, reload])
}, [setError, reload]);
const cstMoveTo = useCallback(
(data: ICstMovetoData, callback?: () => void) => {
@ -273,5 +281,5 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
}}>
{ children }
</RSFormContext.Provider>
)
);
}

View File

@ -1,25 +0,0 @@
import { useState } from 'react'
import { type ErrorInfo } from '../components/BackendError';
import { DataCallback, postNewRSForm } from '../utils/backendAPI';
import { IRSFormCreateData, IRSFormMeta } from '../utils/models';
function useNewRSForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined);
function createSchema(data: IRSFormCreateData, onSuccess: DataCallback<IRSFormMeta>) {
setError(undefined);
postNewRSForm({
data: data,
showError: true,
setLoading: setLoading,
onError: error => { setError(error); },
onSuccess: onSuccess
});
}
return { createSchema, error, setError, loading };
}
export default useNewRSForm;

View File

@ -51,6 +51,10 @@
@apply border-gray-400 dark:border-gray-300 bg-white dark:bg-gray-700
}
.clr-input {
@apply dark:bg-black bg-white disabled:bg-[#f0f2f7] dark:disabled:bg-gray-700
}
.clr-footer {
@apply text-gray-600 bg-white border-gray-400 dark:bg-gray-700 dark:border-gray-300 dark:text-gray-300
}
@ -68,11 +72,11 @@
}
.clr-btn-primary {
@apply text-white bg-blue-400 hover:bg-blue-600 dark:bg-orange-600 dark:hover:bg-orange-400 disabled:bg-gray-400 dark:disabled:bg-gray-400
@apply text-white bg-blue-400 hover:bg-blue-600 dark:bg-orange-600 dark:hover:bg-orange-400 disabled:bg-gray-400 dark:disabled:bg-gray-600
}
.clr-btn-default {
@apply text-gray-500 dark:text-zinc-200 dark:disabled:text-zinc-400 disabled:text-gray-400 bg-gray-100 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-400
@apply text-gray-600 dark:text-zinc-200 dark:disabled:text-zinc-400 disabled:text-gray-400 bg-[#f0f2f7] hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-400
}
/* Transparent button */

View File

@ -10,12 +10,12 @@ import SubmitButton from '../components/Common/SubmitButton';
import TextArea from '../components/Common/TextArea';
import TextInput from '../components/Common/TextInput';
import RequireAuth from '../components/RequireAuth';
import useNewRSForm from '../hooks/useNewRSForm';
import { IRSFormCreateData, IRSFormMeta } from '../utils/models';
import { useLibrary } from '../context/LibraryContext';
import { IRSFormCreateData } from '../utils/models';
function CreateRSFormPage() {
const navigate = useNavigate();
const { createSchema, error, setError, loading } = useNewRSForm()
const { createSchema, error, setError, processing } = useLibrary();
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
@ -35,14 +35,9 @@ function CreateRSFormPage() {
}
}
function onSuccess(newSchema: IRSFormMeta) {
toast.success('Схема успешно создана');
navigate(`/rsforms/${newSchema.id}`);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (loading) {
if (processing) {
return;
}
const data: IRSFormCreateData = {
@ -53,7 +48,10 @@ function CreateRSFormPage() {
file: file,
fileName: file?.name
};
createSchema(data, onSuccess);
createSchema(data, (newSchema) => {
toast.success('Схема успешно создана');
navigate(`/rsforms/${newSchema.id}`);
});
}
return (
@ -92,7 +90,7 @@ function CreateRSFormPage() {
<div className='flex items-center justify-center py-2 mt-4'>
<SubmitButton
text='Создать схему'
loading={loading}
loading={processing}
/>
</div>
{ error && <BackendError error={error} />}

View File

@ -1,3 +1,4 @@
import { useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
@ -5,11 +6,14 @@ import { useAuth } from '../context/AuthContext';
function HomePage() {
const navigate = useNavigate();
const { user } = useAuth();
useLayoutEffect(() => {
if (!user) {
navigate('/library?filter=common');
} else if(!user.is_staff) {
navigate('/library?filter=personal');
}
}, [navigate, user])
return (
<div className='flex flex-col items-center justify-center w-full py-2'>

View File

@ -70,7 +70,7 @@ function ViewLibrary({ schemas }: ViewLibraryProps) {
noDataComponent={<span className='flex flex-col justify-center p-2 text-center'>
<p>Список схем пуст</p>
<p>Измените фильтр или создайти новую концептуальную схему</p>
<p>Измените фильтр или создайте новую концептуальную схему</p>
</span>}
pagination

View File

@ -6,7 +6,6 @@ import Form from '../components/Common/Form';
import SubmitButton from '../components/Common/SubmitButton';
import TextInput from '../components/Common/TextInput';
import TextURL from '../components/Common/TextURL';
import InfoMessage from '../components/InfoMessage';
import { useAuth } from '../context/AuthContext';
import { IUserLoginData } from '../utils/models';
@ -41,7 +40,7 @@ function LoginPage() {
return (
<div className='w-full py-2'> { user
? <InfoMessage message={`Вы вошли в систему как ${user.username}`} />
? <b>{`Вы вошли в систему как ${user.username}`}</b>
: <Form title='Ввод данных пользователя' onSubmit={handleSubmit} widthClass='w-[20rem]'>
<TextInput id='username'
label='Имя пользователя'

View File

@ -16,10 +16,6 @@ interface DlgShowASTProps {
function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
const { darkMode } = useConceptTheme();
function handleSubmit() {
// Do nothing
}
const nodes: GraphNode[] = useMemo(
() => syntaxTree.map(node => {
return {
@ -46,14 +42,12 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
return (
<Modal
hideWindow={hideWindow}
onSubmit={handleSubmit}
submitText='Закрыть'
canSubmit={true}
readonly
>
<div className='flex flex-col items-start gap-2'>
<div className='w-full text-lg text-center'>{expression}</div>
<div className='flex-wrap w-full h-full overflow-auto'>
<div className='relative w-[1040px] h-[600px] 2xl:w-[1680px] 2xl:h-[600px]'>
<div className='relative w-[1040px] h-[600px] 2xl:w-[1680px] 2xl:h-[600px] max-h-full max-w-full'>
<GraphCanvas
nodes={nodes}
edges={edges}

View File

@ -1,13 +1,15 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ConceptTooltip from '../../components/Common/ConceptTooltip';
import Divider from '../../components/Common/Divider';
import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea';
import { DumpBinIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
import { DumpBinIcon, HelpIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext';
import { type CstType, EditMode, ICstUpdateData, SyntaxTree } from '../../utils/models';
import { getCstTypeLabel } from '../../utils/staticUI';
import { getCstTypeLabel, getCstTypificationLabel, mapStatusInfo } from '../../utils/staticUI';
import EditorRSExpression from './EditorRSExpression';
import ViewSideConstituents from './elements/ViewSideConstituents';
@ -62,9 +64,11 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
setTerm(activeCst.term?.raw ?? '');
setTextDefinition(activeCst.definition?.text?.raw ?? '');
setExpression(activeCst.definition?.formal ?? '');
setTypification(activeCst?.parse?.typification || 'N/A');
setTypification(activeCst ? getCstTypificationLabel(activeCst) : 'N/A');
} else if (schema && schema?.items.length > 0) {
onOpenEdit(schema.items[0].id);
}
}, [activeCst]);
}, [activeCst, onOpenEdit, schema]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@ -105,12 +109,12 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
}
return (
<div className='flex items-start w-full gap-2'>
<form onSubmit={handleSubmit} className='flex-grow min-w-[50rem] max-w-min px-4 py-2 border'>
<div className='flex items-stretch w-full gap-2 mb-2'>
<form onSubmit={handleSubmit} className='flex-grow min-w-[50rem] max-w-min max-h-fit px-4 py-2 border'>
<div className='flex items-start justify-between'>
<button type='submit'
title='Сохранить изменения'
className='px-1 py-1 font-bold rounded whitespace-nowrap disabled:cursor-not-allowed clr-btn-primary'
className='px-1 py-1 font-bold border rounded whitespace-nowrap disabled:cursor-not-allowed clr-btn-primary'
disabled={!isModified || !isEnabled}
>
<SaveIcon size={5} />
@ -150,6 +154,36 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
onClick={handleDelete}
icon={<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} />}
/>
<div id='cst-help' className='flex items-center ml-[0.25rem]'>
<HelpIcon color='text-primary' size={5} />
</div>
<ConceptTooltip anchorSelect='#cst-help'>
<div className='max-w-[35rem]'>
<h1>Подсказки</h1>
<p><b className='text-red'>Изменения сохраняются ПОСЛЕ нажатия на кнопку снизу или слева вверху</b></p>
<p><b>Клик на формальное выражение</b> - обратите внимание на кнопки снизу.<br/>Для каждой есть горячая клавиша в подсказке</p>
<p><b>Список конституент справа</b> - обратите внимание на настройки фильтрации</p>
<p>- слева от ввода текста настраивается набор атрибутов конституенты</p>
<p>- справа от ввода текста настраивается список конституент, которые фильтруются</p>
<p>- текущая конституента выделена цветом строки</p>
<p>- двойной клик / Alt + клик - выбор редактируемой конституенты</p>
<p>- при наведении на ID конституенты отображаются ее атрибуты</p>
<p>- столбец "Описание" содержит один из непустых текстовых атрибутов</p>
<Divider margins='mt-2' />
<h1>Статусы</h1>
{ [... mapStatusInfo.values()].map(info => {
return (<p className='py-1'>
<span className={`inline-block font-semibold min-w-[4rem] text-center border ${info.color}`}>
{info.text}
</span>
<span> - </span>
<span>
{info.tooltip}
</span>
</p>);
})}
</div>
</ConceptTooltip>
</div>
</div>
<TextArea id='term' label='Термин'
@ -167,6 +201,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
disabled
/>
<EditorRSExpression id='expression' label='Формальное выражение'
activeCst={activeCst}
placeholder='Родоструктурное выражение, задающее формальное определение'
value={expression}
disabled={!isEnabled}

View File

@ -10,7 +10,7 @@ import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext';
import { prefixes } from '../../utils/constants';
import { CstType, IConstituenta, ICstMovetoData } from '../../utils/models'
import { getCstTypePrefix, getCstTypeShortcut, getTypeLabel, mapStatusInfo } from '../../utils/staticUI';
import { getCstTypePrefix, getCstTypeShortcut, getCstTypificationLabel, mapStatusInfo } from '../../utils/staticUI';
interface EditorItemsProps {
onOpenEdit: (cstID: number) => void
@ -192,10 +192,9 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
{
name: 'Тип',
id: 'type',
cell: (cst: IConstituenta) => <div style={{ fontSize: 12 }}>{getTypeLabel(cst)}</div>,
width: '140px',
minWidth: '100px',
maxWidth: '140px',
cell: (cst: IConstituenta) => <div style={{ fontSize: 12 }}>{getCstTypificationLabel(cst)}</div>,
width: '175px',
maxWidth: '175px',
wrap: true,
reorder: true,
hide: 1600
@ -249,7 +248,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
<div className='w-full'>
<div
className={'flex justify-start w-full gap-1 px-2 py-1 border-y items-center h-[2.2rem] clr-app' +
(!noNavigation ? ' sticky z-10 top-[4rem]' : ' sticky z-10 top-[0rem]')}
(!noNavigation ? ' sticky z-10 top-[0rem]' : ' sticky z-10 top-[0rem]')}
>
<div className='mr-3 whitespace-nowrap'>
Выбраны
@ -257,25 +256,25 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
<b>{selected.length}</b> из {schema?.stats?.count_all ?? 0}
</span>
</div>
{isEditable && <div className='flex items-center justify-start w-full gap-1'>
<div className='flex items-center justify-start w-full gap-1'>
<Button
tooltip='Переместить вверх'
icon={<ArrowUpIcon size={6}/>}
disabled={nothingSelected}
disabled={!isEditable || nothingSelected}
dense
onClick={handleMoveUp}
/>
<Button
tooltip='Переместить вниз'
icon={<ArrowDownIcon size={6}/>}
disabled={nothingSelected}
disabled={!isEditable || nothingSelected}
dense
onClick={handleMoveDown}
/>
<Button
tooltip='Удалить выбранные'
icon={<DumpBinIcon color={!nothingSelected ? 'text-red' : ''} size={6}/>}
disabled={nothingSelected}
disabled={!isEditable || nothingSelected}
dense
onClick={handleDelete}
/>
@ -284,12 +283,14 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
tooltip='Переиндексировать имена'
icon={<ArrowsRotateIcon color='text-primary' size={6}/>}
dense
disabled={!isEditable}
onClick={handleReindex}
/>
<Button
tooltip='Новая конституента'
icon={<SmallPlusIcon color='text-green' size={6}/>}
dense
disabled={!isEditable}
onClick={() => handleCreateCst()}
/>
{(Object.values(CstType)).map(
@ -299,6 +300,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
text={`${getCstTypePrefix(type)}`}
tooltip={getCstTypeShortcut(type)}
dense
disabled={!isEditable}
onClick={() => handleCreateCst(type)}
/>;
})}
@ -328,7 +330,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
})}
</div>
</ConceptTooltip>
</div>}
</div>
</div>
<div className='w-full h-full' onKeyDown={handleTableKey}>
<ConceptDataTable

View File

@ -7,8 +7,8 @@ import { Loader } from '../../components/Common/Loader';
import { useRSForm } from '../../context/RSFormContext';
import useCheckExpression from '../../hooks/useCheckExpression';
import { TokenID } from '../../utils/enums';
import { IRSErrorDescription, SyntaxTree } from '../../utils/models';
import { getCstExpressionPrefix } from '../../utils/staticUI';
import { IConstituenta, IRSErrorDescription, SyntaxTree } from '../../utils/models';
import { getCstExpressionPrefix, getTypificationLabel } from '../../utils/staticUI';
import ParsingResult from './elements/ParsingResult';
import RSLocalButton from './elements/RSLocalButton';
import RSTokenButton from './elements/RSTokenButton';
@ -17,6 +17,7 @@ import { getSymbolSubstitute, TextWrapper } from './elements/textEditing';
interface EditorRSExpressionProps {
id: string
activeCst?: IConstituenta
label: string
isActive: boolean
disabled?: boolean
@ -30,10 +31,10 @@ interface EditorRSExpressionProps {
}
function EditorRSExpression({
id, label, disabled, isActive, placeholder, value, setValue, onShowAST,
id, activeCst, label, disabled, isActive, placeholder, value, setValue, onShowAST,
toggleEditMode, setTypification, onChange
}: EditorRSExpressionProps) {
const { schema, activeCst } = useRSForm();
const { schema } = useRSForm();
const [isModified, setIsModified] = useState(false);
const { parseData, checkExpression, resetParse, loading } = useCheckExpression({ schema });
const expressionCtrl = useRef<HTMLTextAreaElement>(null);
@ -66,7 +67,11 @@ function EditorRSExpression({
}
expressionCtrl.current!.focus();
setIsModified(false);
setTypification(parse.typification);
setTypification(getTypificationLabel({
isValid: parse.parseResult,
resultType: parse.typification,
args: parse.args
}));
});
}
@ -205,13 +210,22 @@ function EditorRSExpression({
return (
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3 w-full'>
<div className='relative w-full'>
<div className='absolute top-[-0.3rem] right-0'>
<StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>
</div>
</div>
<Label
text={label}
required={false}
htmlFor={id}
/>
<textarea id={id} ref={expressionCtrl}
className='w-full px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800'
className='w-full px-3 py-2 mt-2 leading-tight border shadow clr-input'
rows={6}
placeholder={placeholder}
value={value}
@ -223,23 +237,15 @@ function EditorRSExpression({
/>
<div className='flex w-full gap-4 py-1 mt-1 justify-stretch'>
<div className='flex flex-col gap-2'>
{isActive && <StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>}
<Button
tooltip='Проверить формальное выражение'
text='Проверить'
widthClass='h-full w-fit'
colorClass='clr-btn-default'
onClick={handleCheckExpression}
/>
</div>
{isActive && EditButtons}
{!isActive && <StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>}
</div>
{ (loading || parseData) &&
<div className='w-full overflow-y-auto border mt-2 max-h-[14rem] min-h-[7rem]'>

View File

@ -1,35 +1,64 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, useSelection } from 'reagraph';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, Sphere, useSelection } from 'reagraph';
import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox';
import ConceptSelect from '../../components/Common/ConceptSelect';
import { ArrowsRotateIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext';
import useLocalStorage from '../../hooks/useLocalStorage';
import { resources } from '../../utils/constants';
import { Graph } from '../../utils/Graph';
import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI';
function EditorTermGraph() {
const { schema } = useRSForm();
const { darkMode } = useConceptTheme();
const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'forceatlas2');
const { darkMode, noNavigation } = useConceptTheme();
const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'treeTd2d');
const [ filtered, setFiltered ] = useState<Graph>(new Graph());
const [ orbit, setOrbit ] = useState(false);
const [ noHermits, setNoHermits ] = useLocalStorage('graph_no_hermits', true);
const [ noTransitive, setNoTransitive ] = useLocalStorage('graph_no_transitive', false);
const graphRef = useRef<GraphCanvasRef | null>(null);
useEffect(() => {
if (!schema) {
setFiltered(new Graph());
return;
}
const graph = schema.graph.clone();
if (noHermits) {
graph.removeIsolated();
}
if (noTransitive) {
graph.transitiveReduction();
}
setFiltered(graph);
}, [schema, noHermits, noTransitive]);
const nodes: GraphNode[] = useMemo(() => {
return schema?.items.map(cst => {
return {
id: String(cst.id),
label: (cst.term.resolved || cst.term.raw) ? `${cst.alias}: ${cst.term.resolved || cst.term.raw}` : cst.alias
}}
) ?? [];
}, [schema?.items]);
const result: GraphNode[] = [];
if (!schema) {
return result;
}
filtered.nodes.forEach(node => {
const cst = schema.items.find(cst => cst.id === node.id);
if (cst) {
result.push({
id: String(node.id),
label: cst.term.resolved ? `${cst.alias}: ${cst.term.resolved}` : cst.alias
});
}
});
return result;
}, [schema, filtered.nodes]);
const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = [];
let edgeID = 1;
schema?.graph.nodes.forEach(source => {
filtered.nodes.forEach(source => {
source.outputs.forEach(target => {
result.push({
id: String(edgeID),
@ -40,7 +69,7 @@ function EditorTermGraph() {
});
});
return result;
}, [schema?.graph]);
}, [filtered.nodes]);
const handleCenter = useCallback(() => {
graphRef.current?.resetControls();
@ -62,29 +91,47 @@ function EditorTermGraph() {
focusOnSelect: false
});
const canvasSize = !noNavigation ?
'w-[1240px] h-[736px] 2xl:w-[1880px] 2xl:h-[746px]'
: 'w-[1240px] h-[800px] 2xl:w-[1880px] 2xl:h-[810px]';
return (<>
<div className='relative w-full'>
<div className='absolute top-0 left-0 z-20 px-3 py-2 w-[12rem] flex flex-col gap-2'>
<div className='absolute top-0 left-0 z-20 py-2 w-[12rem] flex flex-col'>
<div className='flex items-center gap-1 w-[15rem]'>
<Button
icon={<ArrowsRotateIcon size={8} />}
dense
tooltip='Центрировать изображение'
widthClass='h-full'
onClick={handleCenter}
/>
<ConceptSelect
options={GraphLayoutSelector}
placeholder='Выберите тип'
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []}
onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }}
/>
</div>
<Checkbox
label='Анимация вращения'
widthClass='w-full'
value={orbit}
onChange={ event => setOrbit(event.target.checked) }/>
<Button
text='Центрировать'
dense
onClick={handleCenter}
onChange={ event => setOrbit(event.target.checked) }
/>
<Checkbox
label='Удалить несвязанные'
value={noHermits}
onChange={ event => setNoHermits(event.target.checked) }
/>
<Checkbox
label='Транзитивная редукция'
value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) }
/>
</div>
</div>
<div className='flex-wrap w-full h-full overflow-auto'>
<div className='relative w-[1240px] h-[800px] 2xl:w-[1880px] 2xl:h-[800px]'>
<div className={`relative border-t border-r ${canvasSize}`}>
<GraphCanvas
draggable
ref={graphRef}
@ -99,9 +146,12 @@ function EditorTermGraph() {
onNodePointerOver={onNodePointerOver}
onNodePointerOut={onNodePointerOut}
cameraMode={ orbit ? 'orbit' : layout.includes('3d') ? 'rotate' : 'pan'}
layoutOverrides={ layout.includes('tree') ? { nodeLevelRatio: 1 } : undefined }
layoutOverrides={ layout.includes('tree') ? { nodeLevelRatio: schema && schema?.items.length < 50 ? 3 : 1 } : undefined }
labelFontUrl={resources.graph_font}
theme={darkMode ? darkTheme : lightTheme}
renderNode={({ node, ...rest }) => (
<Sphere {...rest} node={node} />
)}
/>
</div>
</div>

View File

@ -28,7 +28,6 @@ function RSTabsMenu({showUploadDialog, showCloneDialog}: RSTabsMenuProps) {
const schemaMenu = useDropdown();
const editMenu = useDropdown();
const handleClaimOwner = useCallback(() => {
editMenu.hide();
claimOwnershipProc(claim)
@ -61,12 +60,13 @@ function RSTabsMenu({showUploadDialog, showCloneDialog}: RSTabsMenuProps) {
}, [schemaMenu]);
return (
<div className='flex items-center w-fit'>
<div className='flex items-stretch w-fit'>
<div ref={schemaMenu.ref}>
<Button
tooltip='Действия'
icon={<MenuIcon size={5}/>}
borderClass=''
widthClass='h-full w-fit'
dense
onClick={schemaMenu.toggle}
/>
@ -108,6 +108,7 @@ function RSTabsMenu({showUploadDialog, showCloneDialog}: RSTabsMenuProps) {
<Button
tooltip={'измнение: ' + (isEditable ? '[доступно]' : '[запрещено]')}
borderClass=''
widthClass='h-full w-fit'
icon={<PenIcon size={5} color={isEditable ? 'text-green' : 'text-red'}/>}
dense
onClick={editMenu.toggle}
@ -144,6 +145,7 @@ function RSTabsMenu({showUploadDialog, showCloneDialog}: RSTabsMenuProps) {
? <EyeIcon color='text-primary' size={5}/>
: <EyeOffIcon size={5}/>
}
widthClass='h-full w-fit'
borderClass=''
dense
onClick={toggleTracking}

View File

@ -1,6 +1,6 @@
import ConceptTooltip from '../../../components/Common/ConceptTooltip';
import { IConstituenta } from '../../../utils/models';
import { getTypeLabel } from '../../../utils/staticUI';
import { getCstTypificationLabel } from '../../../utils/staticUI';
interface ConstituentaTooltipProps {
data: IConstituenta
@ -14,7 +14,7 @@ function ConstituentaTooltip({ data, anchor }: ConstituentaTooltipProps) {
className='max-w-[25rem] min-w-[25rem]'
>
<h1>Конституента {data.alias}</h1>
<p><b>Типизация: </b>{getTypeLabel(data)}</p>
<p><b>Типизация: </b>{getCstTypificationLabel(data)}</p>
<p><b>Термин: </b>{data.term.resolved || data.term.raw}</p>
{data.definition.formal && <p><b>Выражение: </b>{data.definition.formal}</p>}
{data.definition.text.resolved && <p><b>Определение: </b>{data.definition.text.resolved}</p>}

View File

@ -0,0 +1,66 @@
import { useCallback } from 'react';
import Dropdown from '../../../components/Common/Dropdown';
import DropdownButton from '../../../components/Common/DropdownButton';
import useDropdown from '../../../hooks/useDropdown';
import { DependencyMode } from '../../../utils/models';
import { getDependencyLabel } from '../../../utils/staticUI';
interface DependencyModePickerProps {
value: DependencyMode
onChange: (value: DependencyMode) => void
}
function DependencyModePicker({ value, onChange }: DependencyModePickerProps) {
const pickerMenu = useDropdown();
const handleChange = useCallback(
(newValue: DependencyMode) => {
pickerMenu.hide();
onChange(newValue);
}, [pickerMenu, onChange]);
return (
<div ref={pickerMenu.ref}>
<span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1}
onClick={pickerMenu.toggle}
>
{getDependencyLabel(value)}
</span>
{ pickerMenu.isActive &&
<Dropdown stretchLeft >
<DropdownButton onClick={() => handleChange(DependencyMode.ALL)}>
<p><b>вся схема:</b> список всех конституент схемы</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(DependencyMode.EXPRESSION)}>
<p><b>выражение:</b> список идентификаторов из выражения</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(DependencyMode.OUTPUTS)}>
<p><b>потребители:</b> конституенты, ссылающиеся на данную</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(DependencyMode.INPUTS)}>
<p><b>поставщики:</b> конституенты, на которые ссылается данная</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(DependencyMode.EXPAND_OUTPUTS)}>
<p><b>зависимые:</b> конституенты, зависящие по цепочке</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(DependencyMode.EXPAND_INPUTS)}>
<p><b>влияющие:</b> конституенты, влияющие на данную (цепочка)</p>
</DropdownButton>
</Dropdown>
}
</div>
// case DependencyMode.OUTPUTS: return 'потребители';
// case DependencyMode.INPUTS: return 'поставщики';
// case DependencyMode.EXPAND_INPUTS: return 'влияющие';
// case DependencyMode.EXPAND_OUTPUTS: return 'зависимые';
// }
// }
);
}
export default DependencyModePicker;

View File

@ -0,0 +1,55 @@
import { useCallback } from 'react';
import Dropdown from '../../../components/Common/Dropdown';
import DropdownButton from '../../../components/Common/DropdownButton';
import useDropdown from '../../../hooks/useDropdown';
import { CstMatchMode } from '../../../utils/models';
import { getCstCompareLabel } from '../../../utils/staticUI';
interface MatchModePickerProps {
value: CstMatchMode
onChange: (value: CstMatchMode) => void
}
function MatchModePicker({ value, onChange }: MatchModePickerProps) {
const pickerMenu = useDropdown();
const handleChange = useCallback(
(newValue: CstMatchMode) => {
pickerMenu.hide();
onChange(newValue);
}, [pickerMenu, onChange]);
return (
<div ref={pickerMenu.ref}>
<span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1}
onClick={pickerMenu.toggle}
>
{getCstCompareLabel(value)}
</span>
{ pickerMenu.isActive &&
<Dropdown>
<DropdownButton onClick={() => handleChange(CstMatchMode.ALL)}>
<p><b>везде:</b> искать во всех атрибутах</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(CstMatchMode.EXPR)}>
<p><b>выраж:</b> искать в формальных выражениях</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(CstMatchMode.TERM)}>
<p><b>термин:</b> искать в терминах</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(CstMatchMode.TEXT)}>
<p><b>текст:</b> искать в определениях и конвенциях</p>
</DropdownButton>
<DropdownButton onClick={() => handleChange(CstMatchMode.NAME)}>
<p><b>ID:</b> искать в идентификаторах конституент</p>
</DropdownButton>
</Dropdown>
}
</div>
);
}
export default MatchModePicker;

View File

@ -1,4 +1,3 @@
import Card from '../../../components/Common/Card';
import Divider from '../../../components/Common/Divider';
import LabeledText from '../../../components/Common/LabeledText';
import { type IRSFormStats } from '../../../utils/models';
@ -9,7 +8,7 @@ interface RSFormStatsProps {
function RSFormStats({ stats }: RSFormStatsProps) {
return (
<Card>
<div className='px-4 py-2 border'>
<LabeledText id='count_all'
label='Всего конституент '
text={stats.count_all}
@ -28,12 +27,12 @@ function RSFormStats({ stats }: RSFormStatsProps) {
label='Невычислимы '
text={stats.count_incalc}
/>}
<Divider />
<Divider margins='my-1' />
<LabeledText id='count_termin'
label='Термины '
text={stats.count_termin}
/>
<Divider />
<Divider margins='my-1' />
{ stats.count_base > 0 &&
<LabeledText id='count_base'
label='Базисные множества '
@ -74,7 +73,7 @@ function RSFormStats({ stats }: RSFormStatsProps) {
label='Теормы '
text={stats.count_theorem}
/>}
</Card>
</div>
);
}

View File

@ -24,8 +24,8 @@ function StatusBar({ isModified, constituenta, parseData }: StatusBarProps) {
const data = mapStatusInfo.get(status)!;
return (
<div title={data.tooltip}
className={'min-h-[2rem] min-w-[6rem] font-semibold inline-flex border rounded-lg items-center justify-center align-middle ' + data.color}>
{data.text}
className={`text-sm h-[1.6rem] w-[10rem] font-semibold inline-flex border items-center justify-center align-middle ${data.color}`}>
Статус: [ {data.text} ]
</div>
)
}

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import Checkbox from '../../../components/Common/Checkbox';
import ConceptDataTable from '../../../components/Common/ConceptDataTable';
import { useRSForm } from '../../../context/RSFormContext';
import { useConceptTheme } from '../../../context/ThemeContext';
import useLocalStorage from '../../../hooks/useLocalStorage';
import { prefixes } from '../../../utils/constants';
import { CstType, extractGlobals,type IConstituenta, matchConstituenta } from '../../../utils/models';
import { applyGraphFilter, CstMatchMode, CstType, DependencyMode, extractGlobals, IConstituenta, matchConstituenta } from '../../../utils/models';
import { getCstDescription, getMockConstituenta, mapStatusInfo } from '../../../utils/staticUI';
import ConstituentaTooltip from './ConstituentaTooltip';
import DependencyModePicker from './DependencyModePicker';
import MatchModePicker from './MatchModePicker';
interface ViewSideConstituentsProps {
expression: string
@ -19,31 +20,38 @@ interface ViewSideConstituentsProps {
function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideConstituentsProps) {
const { darkMode } = useConceptTheme();
const { schema } = useRSForm();
const [filterMatch, setFilterMatch] = useLocalStorage('side-filter-match', CstMatchMode.ALL);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '');
const [filterSource, setFilterSource] = useLocalStorage('side-filter-dependency', DependencyMode.ALL);
const [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '')
const [onlyExpression, setOnlyExpression] = useLocalStorage('side-filter-flag', false);
useEffect(() => {
if (!schema?.items) {
setFilteredData([]);
return;
}
if (onlyExpression) {
let filtered: IConstituenta[] = [];
if (filterSource === DependencyMode.EXPRESSION) {
const aliases = extractGlobals(expression);
const filtered = schema?.items.filter((cst) => aliases.has(cst.alias));
filtered = schema.items.filter((cst) => aliases.has(cst.alias));
const names = filtered.map(cst => cst.alias)
const diff = Array.from(aliases).filter(name => !names.includes(name));
if (diff.length > 0) {
diff.forEach(
(alias, index) => filtered.push(getMockConstituenta(-index, alias, CstType.BASE, 'Конституента отсутствует')));
}
setFilteredData(filtered);
} else if (!filterText) {
setFilteredData(schema?.items);
} else if (!activeID) {
filtered = schema.items
} else {
setFilteredData(schema?.items.filter((cst) => matchConstituenta(filterText, cst)));
filtered = applyGraphFilter(schema, activeID, filterSource);
}
}, [filterText, setFilteredData, onlyExpression, expression, schema]);
if (filterText) {
filtered = filtered.filter((cst) => matchConstituenta(filterText, cst, filterMatch));
}
setFilteredData(filtered);
}, [filterText, setFilteredData, filterSource, expression, schema, filterMatch, activeID]);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
@ -132,23 +140,17 @@ function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideCons
);
return (
<div className='max-h-[80vh] overflow-y-scroll border flex-grow w-full'>
<div className='sticky top-0 left-0 right-0 z-10 flex items-center justify-between w-full gap-1 px-2 py-1 bg-white border-b-2 border-gray-400 rounded dark:bg-gray-700 dark:border-gray-300'>
<div className='w-full'>
<div className='max-h-[calc(100vh-10.3rem)] min-h-[40rem] overflow-y-scroll border flex-grow w-full'>
<div className='sticky top-0 left-0 right-0 z-10 flex items-center justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'>
<div className='flex items-center justify-between w-full'>
<MatchModePicker value={filterMatch} onChange={setFilterMatch}/>
<input type='text'
className='w-full px-2 outline-none dark:bg-gray-700 hover:text-clip'
placeholder='текст для фильтрации списка'
className='w-full px-2 bg-white outline-none hover:text-clip clr-bg-pop clr-border'
placeholder='наберите текст фильтра'
value={filterText}
onChange={event => { setFilterText(event.target.value); }}
disabled={onlyExpression}
/>
</div>
<div className='w-fit min-w-[8rem]'>
<Checkbox
label='из выражения'
value={onlyExpression}
onChange={event => { setOnlyExpression(event.target.checked); }}
/>
<DependencyModePicker value={filterSource} onChange={setFilterSource}/>
</div>
</div>
<ConceptDataTable

View File

@ -6,7 +6,6 @@ import BackendError from '../components/BackendError';
import Form from '../components/Common/Form';
import SubmitButton from '../components/Common/SubmitButton';
import TextInput from '../components/Common/TextInput';
import InfoMessage from '../components/InfoMessage';
import { useAuth } from '../context/AuthContext';
import { type IUserSignupData } from '../utils/models';
@ -46,7 +45,7 @@ function RegisterPage() {
return (
<div className='w-full py-2'>
{ user &&
<InfoMessage message={`Вы вошли в систему как ${user.username}. Если хотите зарегистрировать нового пользователя, выйдите из системы (меню в правом верхнем углу экрана)`} /> }
<b>{`Вы вошли в систему как ${user.username}. Если хотите зарегистрировать нового пользователя, выйдите из системы (меню в правом верхнем углу экрана)`}</b>}
{ !user &&
<Form title='Регистрация пользователя' onSubmit={handleSubmit}>
<TextInput id='username' label='Имя пользователя' type='text'

View File

@ -1,8 +1,6 @@
import InfoMessage from '../components/InfoMessage';
function RestorePasswordPage() {
return (
<InfoMessage message='Функционал автоматического восстановления пароля не доступен. Обратитесь в адинистратору' />
<b>Функционал автоматического восстановления пароля не доступен. Обратитесь в адинистратору</b>
);
}

View File

@ -20,6 +20,51 @@ describe('Testing Graph constuction', () => {
expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]);
expect([... graph.nodes.get(1)!.outputs]).toStrictEqual([2]);
});
test('cloning', () => {
const graph = new Graph([[1, 2], [3], [4, 1]]);
const clone = graph.clone();
expect([... graph.nodes.keys()]).toStrictEqual([... clone.nodes.keys()]);
expect([... graph.nodes.values()]).toStrictEqual([... clone.nodes.values()]);
clone.removeNode(3);
expect(clone.nodes.get(3)).toBeUndefined();
expect(graph.nodes.get(3)).not.toBeUndefined();
});
});
describe('Testing Graph editing', () => {
test('removing edges should not remove nodes', () => {
const graph = new Graph([[1, 2], [3], [4, 1]]);
expect(graph.hasEdge(4, 1)).toBeTruthy();
graph.removeEdge(5, 0);
graph.removeEdge(4, 1);
expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]);
expect(graph.hasEdge(4, 1)).toBeFalsy();
});
test('removing isolated nodes', () => {
const graph = new Graph([[9, 1], [9, 2], [2, 1], [4, 3], [5, 9], [7], [8]]);
graph.removeIsolated()
expect([... graph.nodes.keys()]).toStrictEqual([9, 1, 2, 4, 3, 5]);
});
test('transitive reduction', () => {
const graph = new Graph([[1, 3], [1, 2], [2, 3]]);
graph.transitiveReduction()
expect(graph.hasEdge(1, 2)).toBeTruthy();
expect(graph.hasEdge(2, 3)).toBeTruthy();
expect(graph.hasEdge(1, 3)).toBeFalsy();
});
});
describe('Testing Graph sort', () => {
test('topological order', () => {
const graph = new Graph([[9, 1], [9, 2], [2, 1], [4, 3], [5, 9]]);
expect(graph.tolopogicalOrder()).toStrictEqual([5, 4, 3, 9, 1, 2]);
});
});
describe('Testing Graph queries', () => {

View File

@ -10,6 +10,13 @@ export class GraphNode {
this.inputs = [];
}
clone(): GraphNode {
const result = new GraphNode(this.id);
result.outputs = [... this.outputs];
result.inputs = [... this.inputs];
return result;
}
addOutput(node: number): void {
this.outputs.push(node);
}
@ -45,6 +52,12 @@ export class Graph {
});
}
clone(): Graph {
const result = new Graph();
this.nodes.forEach(node => result.nodes.set(node.id, node.clone()));
return result;
}
addNode(target: number): GraphNode {
let node = this.nodes.get(target);
if (!node) {
@ -67,6 +80,16 @@ export class Graph {
return nodeToRemove;
}
removeIsolated(): GraphNode[] {
const result: GraphNode[] = [];
this.nodes.forEach(node => {
if (node.outputs.length === 0 && node.inputs.length === 0) {
this.nodes.delete(node.id);
}
});
return result;
}
addEdge(source: number, destination: number): void {
const sourceNode = this.addNode(source);
const destinationNode = this.addNode(destination);
@ -83,6 +106,14 @@ export class Graph {
}
}
hasEdge(source: number, destination: number): boolean {
const sourceNode = this.nodes.get(source);
if (!sourceNode) {
return false;
}
return !!sourceNode.outputs.find(id => id === destination);
}
expandOutputs(origin: number[]): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();
@ -143,26 +174,62 @@ export class Graph {
return result;
}
visitDFS(visitor: (node: GraphNode) => void) {
const visited: Map<number, boolean> = new Map();
tolopogicalOrder(): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();
this.nodes.forEach(node => {
if (!visited.has(node.id)) {
this.depthFirstSearch(node, visited, visitor);
if (marked.get(node.id)) {
return;
}
const toVisit: number[] = [node.id];
let index = 0;
while (toVisit.length > 0) {
const item = toVisit[index];
if (marked.get(item)) {
if (!result.find(id => id ===item)) {
result.push(item);
}
toVisit.splice(index, 1);
index -= 1;
} else {
marked.set(item, true);
const itemNode = this.nodes.get(item);
if (itemNode && itemNode.outputs.length > 0) {
itemNode.outputs.forEach(child => {
if (!marked.get(child)) {
toVisit.push(child);
}
});
}
if (index + 1 < toVisit.length) {
index += 1;
}
}
}
marked
});
return result.reverse();
}
private depthFirstSearch(
node: GraphNode,
visited: Map<number, boolean>,
visitor: (node: GraphNode) => void)
: void {
visited.set(node.id, true);
visitor(node);
node.outputs.forEach((item) => {
if (!visited.has(item)) {
const childNode = this.nodes.get(item)!;
this.depthFirstSearch(childNode, visited, visitor);
transitiveReduction() {
const order = this.tolopogicalOrder();
const marked = new Map<number, boolean>();
order.forEach(nodeID => {
if (marked.get(nodeID)) {
return;
}
const stack: {id: number, parents: number[]}[] = [];
stack.push({id: nodeID, parents: []});
while (stack.length > 0) {
const item = stack.splice(0, 1)[0];
const node = this.nodes.get(item.id);
if (node) {
node.outputs.forEach(child => {
item.parents.forEach(parent => this.removeEdge(parent, child));
stack.push({id: child, parents: [item.id, ...item.parents]})
});
}
marked.set(item.id, true)
}
});
}

View File

@ -1,6 +1,8 @@
// Constants
const prod = {
backend: 'http://rs.acconcept.ru:8000',
backend: 'https://dev.concept.ru:8000',
// backend: 'https://localhost:8000',
// backend: 'https://api.portal.concept.ru',
};
const dev = {
@ -16,7 +18,8 @@ export const urls = {
exteor64: 'https://drive.google.com/open?id=1IJt25ZRQ-ZMA6t7hOqmo5cv05WJCQKMv&usp=drive_fs',
ponomarev: 'https://inponomarev.ru/textbook',
intro_video: 'https://www.youtube.com/watch?v=0Ty9mu9sOJo',
full_course: 'https://www.youtube.com/playlist?list=PLGe_JiAwpqu1C70ruQmCm_OWTWU3KJwDo'
full_course: 'https://www.youtube.com/playlist?list=PLGe_JiAwpqu1C70ruQmCm_OWTWU3KJwDo',
gitrepo: 'https://github.com/IRBorisov/ConceptPortal'
};
export const resources = {

View File

@ -69,6 +69,11 @@ export interface ISyntaxTreeNode {
}
export type SyntaxTree = ISyntaxTreeNode[]
export interface IFunctionArg {
alias: string
typification: string
}
export interface IExpressionParse {
parseResult: boolean
syntax: Syntax
@ -77,6 +82,7 @@ export interface IExpressionParse {
errors: IRSErrorDescription[]
astText: string
ast: SyntaxTree
args: IFunctionArg[]
}
export interface IRSExpression {
@ -118,6 +124,7 @@ export interface IConstituenta {
valueClass: ValueClass
typification: string
syntaxTree: string
args: IFunctionArg[]
}
}
@ -231,6 +238,25 @@ export enum ExpressionStatus {
VERIFIED
}
// Dependency mode for schema analysis
export enum DependencyMode {
ALL = 0,
EXPRESSION,
OUTPUTS,
INPUTS,
EXPAND_OUTPUTS,
EXPAND_INPUTS
}
// Constituent compare mode
export enum CstMatchMode {
ALL = 1,
EXPR,
TERM,
TEXT,
NAME
}
// ========== Model functions =================
export function inferStatus(parse?: ParsingStatus, value?: ValueClass): ExpressionStatus {
if (!parse || !value) {
@ -322,22 +348,23 @@ export function LoadRSFormData(schema: IRSFormData): IRSForm {
return result;
}
export function matchConstituenta(query: string, target?: IConstituenta) {
if (!target) {
return false;
} else if (target.alias.match(query)) {
export function matchConstituenta(query: string, target: IConstituenta, mode: CstMatchMode) {
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.NAME) &&
target.alias.match(query)) {
return true;
} else if (target.term.resolved.match(query)) {
return true;
} else if (target.definition.formal.match(query)) {
return true;
} else if (target.definition.text.resolved.match(query)) {
return true;
} else if (target.convention.match(query)) {
return true;
} else {
return false;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TERM) &&
target.term.resolved.match(query)) {
return true;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.EXPR) &&
target.definition.formal.match(query)) {
return true;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TEXT)) {
return (target.definition.text.resolved.match(query) || target.convention.match(query));
}
return false;
}
export function matchRSFormMeta(query: string, target: IRSFormMeta) {
@ -350,3 +377,21 @@ export function matchRSFormMeta(query: string, target: IRSFormMeta) {
return false;
}
}
export function applyGraphFilter(schema: IRSForm, start: number, mode: DependencyMode): IConstituenta[] {
if (mode === DependencyMode.ALL) {
return schema.items;
}
let ids: number[] | undefined = undefined
switch (mode) {
case DependencyMode.OUTPUTS: { ids = schema.graph.nodes.get(start)?.outputs; break; }
case DependencyMode.INPUTS: { ids = schema.graph.nodes.get(start)?.inputs; break; }
case DependencyMode.EXPAND_OUTPUTS: { ids = schema.graph.expandOutputs([start]) ; break; }
case DependencyMode.EXPAND_INPUTS: { ids = schema.graph.expandInputs([start]) ; break; }
}
if (!ids) {
return schema.items;
} else {
return schema.items.filter(cst => ids!.find(id => id === cst.id));
}
}

View File

@ -1,7 +1,10 @@
import { LayoutTypes } from 'reagraph';
import { resolveErrorClass,RSErrorClass, RSErrorType, TokenID } from './enums';
import { CstType, ExpressionStatus, type IConstituenta, IRSErrorDescription,type IRSForm, ISyntaxTreeNode,ParsingStatus, ValueClass } from './models';
import { CstMatchMode, CstType, DependencyMode,ExpressionStatus, IConstituenta,
IFunctionArg,IRSErrorDescription, IRSForm,
ISyntaxTreeNode, ParsingStatus, ValueClass
} from './models';
export interface IRSButtonData {
text: string
@ -14,16 +17,6 @@ export interface IStatusInfo {
tooltip: string
}
export function getTypeLabel(cst: IConstituenta): string {
if (cst.parse?.typification) {
return cst.parse.typification;
}
if (cst.parse?.status !== ParsingStatus.VERIFIED) {
return 'N/A';
}
return 'Логический';
}
export function getCstDescription(cst: IConstituenta): string {
if (cst.cstType === CstType.STRUCTURED) {
return (
@ -273,6 +266,27 @@ export const mapLayoutLabels: Map<string, string> = new Map([
['nooverlap', 'Без перекрытия']
]);
export function getCstCompareLabel(mode: CstMatchMode): string {
switch(mode) {
case CstMatchMode.ALL: return 'везде';
case CstMatchMode.EXPR: return 'ФВ';
case CstMatchMode.TERM: return 'термин';
case CstMatchMode.TEXT: return 'текст';
case CstMatchMode.NAME: return 'ID';
}
}
export function getDependencyLabel(mode: DependencyMode): string {
switch(mode) {
case DependencyMode.ALL: return 'вся схема';
case DependencyMode.EXPRESSION: return 'выражение';
case DependencyMode.OUTPUTS: return 'потребители';
case DependencyMode.INPUTS: return 'поставщики';
case DependencyMode.EXPAND_INPUTS: return 'влияющие';
case DependencyMode.EXPAND_OUTPUTS: return 'зависимые';
}
}
export const GraphLayoutSelector: {value: LayoutTypes, label: string}[] = [
{ value: 'forceatlas2', label: 'Атлас 2D'},
{ value: 'forceDirected2d', label: 'Силы 2D'},
@ -360,7 +374,8 @@ export function getMockConstituenta(id: number, alias: string, type: CstType, co
status: ParsingStatus.INCORRECT,
valueClass: ValueClass.INVALID,
typification: 'N/A',
syntaxTree: ''
syntaxTree: '',
args: []
}
};
}
@ -373,6 +388,32 @@ export function getCloneTitle(schema: IRSForm): string {
}
}
export function getTypificationLabel({isValid, resultType, args}: {
isValid: boolean,
resultType: string,
args: IFunctionArg[]
}): string {
if (!isValid) {
return 'N/A';
}
if (resultType === '') {
resultType = 'Логический'
}
if (args.length === 0) {
return resultType;
}
const argsText = args.map(arg => arg.typification).join(', ');
return `${resultType} 🠔 [${argsText}]`;
}
export function getCstTypificationLabel(cst: IConstituenta): string {
return getTypificationLabel({
isValid: cst.parse.status === ParsingStatus.VERIFIED,
resultType: cst.parse.typification,
args: cst.parse.args
});
}
export function getRSErrorPrefix(error: IRSErrorDescription): string {
const id = error.errorType.toString(16)
switch(resolveErrorClass(error.errorType)) {

View File

@ -7,3 +7,11 @@ export function assertIsNode(e: EventTarget | null): asserts e is Node {
export async function delay(ms: number) {
return await new Promise(resolve => setTimeout(resolve, ms));
}
export function trimString(target: string, maxLen: number): string {
if (target.length < maxLen) {
return target;
} else {
return target.substring(0, maxLen) + '...';
}
}

View File

@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({