# 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 # 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 # SECURITY SENSITIVE FILES
# persistent/* secrets/
cert/
# External distributions # External distributions
rsconcept/backend/import/*.whl rsconcept/backend/import/*.whl

22
.vscode/launch.json vendored
View File

@ -52,6 +52,26 @@
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1", "script": "${workspaceFolder}/rsconcept/RunServer.ps1",
"args": ["-freshStart"] "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 rsconcept\backend\LocalEnvSetup.ps1
- run 'npm install' in rsconcept\frontend - run 'npm install' in rsconcept\frontend
- use VSCode configs in root folder to start developement - 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] # Frontend stack & Tooling [Vite + React + Typescript]
<details> <details>

View File

@ -2,6 +2,23 @@
This list only contains global tech refactorings and tech debt This list only contains global tech refactorings and tech debt
For more specific TODOs see comments in code 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 provide initial data for testing
- USe migtation/fixtures to load example common data - 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: volumes:
postgres_volume: postgres_volume:
name: "postgres-db" name: "postgresql-db"
django_static_volume: django_static_volume:
name: "static" name: "static"
django_media_volume: django_media_volume:
@ -12,6 +10,12 @@ networks:
default: default:
name: concept-api-net name: concept-api-net
secrets:
django_key:
file: ./secrets/django_key.txt
db_password:
file: ./secrets/db_password.txt
services: services:
frontend: frontend:
restart: always restart: always
@ -19,8 +23,8 @@ services:
- backend - backend
build: build:
context: ./rsconcept/frontend context: ./rsconcept/frontend
ports: expose:
- 3000:3000 - 3000
command: serve -s /home/node -l 3000 command: serve -s /home/node -l 3000
@ -28,12 +32,17 @@ services:
restart: always restart: always
depends_on: depends_on:
- postgresql-db - postgresql-db
- nginx secrets:
- db_password
- django_key
build: build:
context: ./rsconcept/backend context: ./rsconcept/backend
env_file: ./rsconcept/backend/.env.dev env_file: ./rsconcept/backend/.env.prod
ports: environment:
- 8000:8000 SECRET_KEY: /run/secrets/django_key
DB_PASSWORD: /run/secrets/db_password
expose:
- 8000
volumes: volumes:
- django_static_volume:/home/app/web/static - django_static_volume:/home/app/web/static
- django_media_volume:/home/app/web/media - django_media_volume:/home/app/web/media
@ -44,7 +53,11 @@ services:
postgresql-db: postgresql-db:
restart: always restart: always
image: postgres:alpine image: postgres:alpine
env_file: ./postgresql/.env.dev secrets:
- db_password
env_file: ./postgresql/.env.prod
environment:
POSTGRES_PASSWORD: /run/secrets/db_password
volumes: volumes:
- postgres_volume:/var/lib/postgresql/data - postgres_volume:/var/lib/postgresql/data
@ -54,9 +67,11 @@ services:
build: build:
context: ./nginx context: ./nginx
ports: 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;\"'" command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
volumes: volumes:
- django_static_volume:/var/www/static - django_static_volume:/var/www/static
- django_media_volume:/var/www/media - django_media_volume:/var/www/media

View File

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

View File

@ -1,12 +1,17 @@
upstream innerdjango { upstream innerdjango {
server backend:8000; 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 { server {
listen 80; listen 8000 ssl;
server_name rs.acconcept.ru; 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 / { location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -19,4 +24,18 @@ server {
location /media/ { location /media/ {
alias /var/www/media/; 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

@ -46,7 +46,7 @@ function AddAdmin {
$env:DJANGO_SUPERUSER_USERNAME = 'admin' $env:DJANGO_SUPERUSER_USERNAME = 'admin'
$env:DJANGO_SUPERUSER_PASSWORD = '1234' $env:DJANGO_SUPERUSER_PASSWORD = '1234'
$env:DJANGO_SUPERUSER_EMAIL = 'admin@admin.com' $env:DJANGO_SUPERUSER_EMAIL = 'admin@admin.com'
& $pyExec $djangoSrc createsuperuser --noinput & $pyExec $djangoSrc createsuperuser --noinput
} }
function DoMigrations { function DoMigrations {

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 rm -rf /wheels
# Copy application sources and setup permissions # Copy application sources and setup permissions
COPY apps/ ./apps/ COPY apps/ ./apps
COPY project/ ./project COPY project/ ./project
COPY data/ ./data
COPY manage.py entrypoint.sh ./ COPY manage.py entrypoint.sh ./
RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \ RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \
chmod +x $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/static && \
chmod -R a+rwx $APP_HOME/media chmod -R a+rwx $APP_HOME/media
RUN
USER app USER app
WORKDIR $APP_HOME WORKDIR $APP_HOME

View File

@ -43,9 +43,15 @@ class ConstituentaSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ('id', 'order', 'alias', 'cst_type') 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() instance.schema.save()
return super().update(instance, validated_data) return result
class StandaloneCstSerializer(serializers.ModelSerializer): class StandaloneCstSerializer(serializers.ModelSerializer):

View File

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

View File

@ -31,6 +31,10 @@ class TestConstituentaAPI(APITestCase):
alias='X1', schema=self.rsform_owned, order=1, convention='Test') alias='X1', schema=self.rsform_owned, order=1, convention='Test')
self.cst2 = Constituenta.objects.create( self.cst2 = Constituenta.objects.create(
alias='X2', schema=self.rsform_unowned, order=1, convention='Test1') 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): def test_retrieve(self):
response = self.client.get(f'/api/constituents/{self.cst1.id}/') 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') response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
self.assertEqual(response.status_code, 200) 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): def test_readonly_cst_fields(self):
data = json.dumps({'alias': 'X33', 'order': 10}) data = json.dumps({'alias': 'X33', 'order': 10})
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')

View File

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

View File

@ -82,9 +82,9 @@ MIDDLEWARE = [
] ]
ROOT_URLCONF = 'project.urls' ROOT_URLCONF = 'project.urls'
LOGIN_URL = '/accounts/login/' LOGIN_URL = '/admin/login/'
LOGIN_REDIRECT_URL = '/home' LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/home' LOGOUT_REDIRECT_URL = '/'
TEMPLATES = [ 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 ./ ./ COPY ./ ./
RUN npm install RUN npm install
ENV NODE_ENV production
RUN npm run build RUN npm run build
# ========= Server ======= # ========= Server =======

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import Footer from './components/Footer'; import Footer from './components/Footer';
@ -16,9 +17,21 @@ import RSFormPage from './pages/RSFormPage';
import UserProfilePage from './pages/UserProfilePage'; import UserProfilePage from './pages/UserProfilePage';
function App () { function App () {
const {noNavigation} = useConceptTheme() 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 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 ( return (
<div className='antialiased clr-app'> <div className='antialiased clr-app'>
<Navigation /> <Navigation />
@ -28,7 +41,9 @@ function App () {
draggable={false} draggable={false}
pauseOnFocusLoss={false} pauseOnFocusLoss={false}
/> />
<main className={main_clsN}>
<div className={`${scrollWindowSize} overflow-auto`}>
<main className={`${mainSize} px-2`}>
<Routes> <Routes>
<Route path='/' element={ <HomePage/>} /> <Route path='/' element={ <HomePage/>} />
@ -46,6 +61,7 @@ function App () {
</Routes> </Routes>
</main> </main>
<Footer /> <Footer />
</div>
</div> </div>
); );
} }

View File

@ -13,7 +13,9 @@ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'child
function Button({ function Button({
id, text, icon, tooltip, id, text, icon, tooltip,
dense, disabled, 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, loading, onClick,
...props ...props
}: ButtonProps) { }: ButtonProps) {

View File

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

View File

@ -8,7 +8,7 @@ interface SubmitButtonProps {
function SubmitButton({ text = 'ОК', icon, disabled, loading = false }: SubmitButtonProps) { function SubmitButton({ text = 'ОК', icon, disabled, loading = false }: SubmitButtonProps) {
return ( return (
<button type='submit' <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} disabled={disabled ?? loading}
> >
{icon && <span>{icon}</span>} {icon && <span>{icon}</span>}

View File

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

View File

@ -3,14 +3,17 @@ import { type InputHTMLAttributes } from 'react';
import Label from './Label'; import Label from './Label';
interface TextInputProps interface TextInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> { extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
id: string id: string
label: string label: string
widthClass?: string widthClass?: string
colorClass?: string
} }
function TextInput({ function TextInput({
id, required, label, widthClass = 'w-full', id, required, label,
widthClass = 'w-full',
colorClass = 'clr-input',
...props ...props
}: TextInputProps) { }: TextInputProps) {
return ( return (
@ -21,7 +24,7 @@ function TextInput({
htmlFor={id} htmlFor={id}
/> />
<input id={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} required={required}
{...props} {...props}
/> />

View File

@ -1,14 +1,19 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { urls } from '../utils/constants'; import { urls } from '../utils/constants';
import { GithubIcon } from './Icons';
function Footer() { function Footer() {
return ( 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='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='/manuals' tabIndex={-1}>Справка</Link> <br/>
<Link to='/library?filter=common' 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>
<div className='px-4 underline border-gray-400 border-x dark:border-gray-300'> <div className='px-4 underline border-gray-400 border-x dark:border-gray-300'>
<ul> <ul>
@ -23,9 +28,9 @@ function Footer() {
</li> </li>
</ul> </ul>
</div> </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 className='mt-0.5'>© 2023 ЦИВТ КОНЦЕПТ</p>
<p>Данный инструмент работы с экспликациями концептуальных схем в родоструктурной форме является уникальной Российской разработкой и вобрал в себя разработки начиная с 1990-х годов</p> <p>Портал позволяет анализировать предметные области, формально записывать системы определений и синтезировать их с помощью математического аппарата родов структур</p>
</div> </div>
</div> </div>
</footer > </footer >

View File

@ -292,3 +292,11 @@ export function HelpIcon(props: IconProps) {
</IconSVG> </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') }; const navigateHelp = () => { navigate('/manuals') };
return ( 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 && {!noNavigation &&
<button <button
title='Скрыть навигацию' title='Скрыть навигацию'

View File

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

View File

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

View File

@ -10,6 +10,7 @@ interface IAuthContext {
login: (data: IUserLoginData, callback?: DataCallback) => void login: (data: IUserLoginData, callback?: DataCallback) => void
logout: (callback?: DataCallback) => void logout: (callback?: DataCallback) => void
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void
reload: (callback?: () => void) => void
loading: boolean loading: boolean
error: ErrorInfo error: ErrorInfo
setError: (error: ErrorInfo) => void setError: (error: ErrorInfo) => void
@ -36,20 +37,19 @@ export const AuthState = ({ children }: AuthStateProps) => {
const [error, setError] = useState<ErrorInfo>(undefined); const [error, setError] = useState<ErrorInfo>(undefined);
const reload = useCallback( const reload = useCallback(
(callback?: () => void) => { (callback?: () => void) => {
getAuth({ getAuth({
onError: () => { setUser(undefined); }, onError: () => { setUser(undefined); },
onSuccess: currentUser => { onSuccess: currentUser => {
if (currentUser.id) { if (currentUser.id) {
setUser(currentUser); setUser(currentUser);
} else { } else {
setUser(undefined); setUser(undefined);
}
if (callback) callback();
} }
}); if (callback) callback();
}, [setUser] }
); });
}, [setUser]);
function login(data: IUserLoginData, callback?: DataCallback) { function login(data: IUserLoginData, callback?: DataCallback) {
setError(undefined); setError(undefined);
@ -87,7 +87,7 @@ export const AuthState = ({ children }: AuthStateProps) => {
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ user, login, logout, signup, loading, error, setError }} value={{ user, login, logout, signup, loading, error, reload, setError }}
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>

View File

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

View File

@ -5,14 +5,17 @@ import { type ErrorInfo } from '../components/BackendError'
import { useRSFormDetails } from '../hooks/useRSFormDetails' import { useRSFormDetails } from '../hooks/useRSFormDetails'
import { import {
type DataCallback, deleteRSForm, getTRSFile, type DataCallback, deleteRSForm, getTRSFile,
patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchResetAliases, patchConstituenta, patchDeleteConstituenta,
patchRSForm, patchMoveConstituenta, patchResetAliases, patchRSForm,
patchUploadTRS, postClaimRSForm, postCloneRSForm,postNewConstituenta} from '../utils/backendAPI' patchUploadTRS, postClaimRSForm, postCloneRSForm, postNewConstituenta
} from '../utils/backendAPI'
import { import {
IConstituentaList, IConstituentaMeta, ICstCreateData, IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData, IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData,
IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
} from '../utils/models' } from '../utils/models'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
import { useLibrary } from './LibraryContext'
interface IRSFormContext { interface IRSFormContext {
schema?: IRSForm schema?: IRSForm
@ -63,15 +66,16 @@ interface RSFormStateProps {
} }
export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const { user } = useAuth() const { user } = useAuth();
const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID }) const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID });
const [processing, setProcessing] = useState(false) const [ processing, setProcessing ] = useState(false);
const library = useLibrary();
const [isForceAdmin, setIsForceAdmin] = useState(false) const [ isForceAdmin, setIsForceAdmin ] = useState(false);
const [isReadonly, setIsReadonly] = useState(false) const [ isReadonly, setIsReadonly ] = useState(false);
const isOwned = 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 isClaimable = useMemo(() => user?.id !== schema?.owner || false, [user, schema?.owner]);
const isEditable = useMemo( const isEditable = useMemo(
() => { () => {
return ( return (
@ -83,181 +87,185 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const isTracking = useMemo( const isTracking = useMemo(
() => { () => {
return true return true
}, []) }, []);
const toggleTracking = useCallback( const toggleTracking = useCallback(
() => { () => {
toast('not implemented yet') toast.info('Отслеживание в разработке...')
}, []) }, []);
const update = useCallback( const update = useCallback(
(data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => { (data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => {
if (!schema) { if (!schema) {
return; return;
}
setError(undefined)
patchRSForm(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
} }
setError(undefined) });
patchRSForm(schemaID, { }, [schemaID, setError, setSchema, schema]);
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
}
});
}, [schemaID, setError, setSchema, schema])
const upload = useCallback( const upload = useCallback(
(data: IRSFormUploadData, callback?: () => void) => { (data: IRSFormUploadData, callback?: () => void) => {
if (!schema) { if (!schema) {
return; return;
}
setError(undefined)
patchUploadTRS(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: newData => {
setSchema(newData);
if (callback) callback();
} }
setError(undefined) });
patchUploadTRS(schemaID, { }, [schemaID, setError, setSchema, schema]);
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(newData);
if (callback) callback();
}
});
}, [schemaID, setError, setSchema, schema])
const destroy = useCallback( const destroy = useCallback(
(callback?: () => void) => { (callback?: () => void) => {
setError(undefined) setError(undefined)
deleteRSForm(schemaID, { deleteRSForm(schemaID, {
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => setError(error),
onSuccess: () => { onSuccess: () => {
setSchema(undefined); setSchema(undefined);
if (callback) callback(); library.reload();
} if (callback) callback();
}); }
}, [schemaID, setError, setSchema]) });
}, [schemaID, setError, setSchema, library]);
const claim = useCallback( const claim = useCallback(
(callback?: DataCallback<IRSFormMeta>) => { (callback?: DataCallback<IRSFormMeta>) => {
if (!schema || !user) { if (!schema || !user) {
return; return;
}
setError(undefined)
postClaimRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
} }
setError(undefined) });
postClaimRSForm(schemaID, { }, [schemaID, setError, schema, user, setSchema]);
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback(newData);
}
});
}, [schemaID, setError, schema, user, setSchema])
const clone = useCallback( const clone = useCallback(
(data: IRSFormCreateData, callback: DataCallback<IRSFormData>) => { (data: IRSFormCreateData, callback: DataCallback<IRSFormData>) => {
if (!schema || !user) { if (!schema || !user) {
return; return;
}
setError(undefined)
postCloneRSForm(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: newSchema => {
library.reload();
if (callback) callback(newSchema);
} }
setError(undefined) });
postCloneRSForm(schemaID, { }, [schemaID, setError, schema, user, library]);
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: callback
});
}, [schemaID, setError, schema, user])
const resetAliases = useCallback( const resetAliases = useCallback(
(callback?: () => void) => { (callback?: () => void) => {
if (!schema || !user) { if (!schema || !user) {
return; return;
}
setError(undefined)
patchResetAliases(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback();
} }
setError(undefined) });
patchResetAliases(schemaID, { }, [schemaID, setError, schema, user, setSchema]);
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback();
}
});
}, [schemaID, setError, schema, user, setSchema])
const download = useCallback( const download = useCallback(
(callback: DataCallback<Blob>) => { (callback: DataCallback<Blob>) => {
setError(undefined) setError(undefined)
getTRSFile(schemaID, { getTRSFile(schemaID, {
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: callback onSuccess: callback
}); });
}, [schemaID, setError]) }, [schemaID, setError]);
const cstCreate = useCallback( const cstCreate = useCallback(
(data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => { (data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => {
setError(undefined) setError(undefined)
postNewConstituenta(schemaID, { postNewConstituenta(schemaID, {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: newData => { onSuccess: newData => {
setSchema(newData.schema); setSchema(newData.schema);
if (callback) callback(newData.new_cst); if (callback) callback(newData.new_cst);
} }
}); });
}, [schemaID, setError, setSchema]); }, [schemaID, setError, setSchema]);
const cstDelete = useCallback( const cstDelete = useCallback(
(data: IConstituentaList, callback?: () => void) => { (data: IConstituentaList, callback?: () => void) => {
setError(undefined) setError(undefined)
patchDeleteConstituenta(schemaID, { patchDeleteConstituenta(schemaID, {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: newData => { onSuccess: newData => {
setSchema(newData); setSchema(newData);
if (callback) callback(); if (callback) callback();
} }
}); });
}, [schemaID, setError, setSchema]); }, [schemaID, setError, setSchema]);
const cstUpdate = useCallback( const cstUpdate = useCallback(
(data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => { (data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => {
setError(undefined) setError(undefined)
patchConstituenta(String(data.id), { patchConstituenta(String(data.id), {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: newData => { onSuccess: newData => {
reload(setProcessing, () => { if (callback != null) callback(newData); }) reload(setProcessing, () => { if (callback != null) callback(newData); })
} }
}); });
}, [setError, reload]) }, [setError, reload]);
const cstMoveTo = useCallback( const cstMoveTo = useCallback(
(data: ICstMovetoData, callback?: () => void) => { (data: ICstMovetoData, callback?: () => void) => {
setError(undefined) setError(undefined)
patchMoveConstituenta(schemaID, { patchMoveConstituenta(schemaID, {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: newData => { onSuccess: newData => {
setSchema(newData); setSchema(newData);
if (callback) callback(); if (callback) callback();
} }
}); });
}, [schemaID, setError, setSchema]); }, [schemaID, setError, setSchema]);
return ( return (
<RSFormContext.Provider value={{ <RSFormContext.Provider value={{
@ -273,5 +281,5 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
}}> }}>
{ children } { children }
</RSFormContext.Provider> </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 @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 { .clr-footer {
@apply text-gray-600 bg-white border-gray-400 dark:bg-gray-700 dark:border-gray-300 dark:text-gray-300 @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 { .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 { .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 */ /* Transparent button */

View File

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

View File

@ -1,3 +1,4 @@
import { useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@ -5,11 +6,14 @@ import { useAuth } from '../context/AuthContext';
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
if (!user) {
navigate('/library?filter=common'); useLayoutEffect(() => {
} else if(!user.is_staff) { if (!user) {
navigate('/library?filter=personal'); navigate('/library?filter=common');
} } else if(!user.is_staff) {
navigate('/library?filter=personal');
}
}, [navigate, user])
return ( return (
<div className='flex flex-col items-center justify-center w-full py-2'> <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'> noDataComponent={<span className='flex flex-col justify-center p-2 text-center'>
<p>Список схем пуст</p> <p>Список схем пуст</p>
<p>Измените фильтр или создайти новую концептуальную схему</p> <p>Измените фильтр или создайте новую концептуальную схему</p>
</span>} </span>}
pagination pagination

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import { useLayoutEffect, useMemo, useState } from 'react'; import { useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import ConceptTooltip from '../../components/Common/ConceptTooltip';
import Divider from '../../components/Common/Divider';
import MiniButton from '../../components/Common/MiniButton'; import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton'; import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea'; 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 { useRSForm } from '../../context/RSFormContext';
import { type CstType, EditMode, ICstUpdateData, SyntaxTree } from '../../utils/models'; 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 EditorRSExpression from './EditorRSExpression';
import ViewSideConstituents from './elements/ViewSideConstituents'; import ViewSideConstituents from './elements/ViewSideConstituents';
@ -62,9 +64,11 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
setTerm(activeCst.term?.raw ?? ''); setTerm(activeCst.term?.raw ?? '');
setTextDefinition(activeCst.definition?.text?.raw ?? ''); setTextDefinition(activeCst.definition?.text?.raw ?? '');
setExpression(activeCst.definition?.formal ?? ''); 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>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@ -105,12 +109,12 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
} }
return ( return (
<div className='flex items-start w-full gap-2'> <div className='flex items-stretch w-full gap-2 mb-2'>
<form onSubmit={handleSubmit} className='flex-grow min-w-[50rem] max-w-min px-4 py-2 border'> <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'> <div className='flex items-start justify-between'>
<button type='submit' <button type='submit'
title='Сохранить изменения' 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} disabled={!isModified || !isEnabled}
> >
<SaveIcon size={5} /> <SaveIcon size={5} />
@ -150,6 +154,36 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
onClick={handleDelete} onClick={handleDelete}
icon={<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} />} 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>
</div> </div>
<TextArea id='term' label='Термин' <TextArea id='term' label='Термин'
@ -167,6 +201,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDe
disabled disabled
/> />
<EditorRSExpression id='expression' label='Формальное выражение' <EditorRSExpression id='expression' label='Формальное выражение'
activeCst={activeCst}
placeholder='Родоструктурное выражение, задающее формальное определение' placeholder='Родоструктурное выражение, задающее формальное определение'
value={expression} value={expression}
disabled={!isEnabled} disabled={!isEnabled}

View File

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

View File

@ -7,8 +7,8 @@ import { Loader } from '../../components/Common/Loader';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import useCheckExpression from '../../hooks/useCheckExpression'; import useCheckExpression from '../../hooks/useCheckExpression';
import { TokenID } from '../../utils/enums'; import { TokenID } from '../../utils/enums';
import { IRSErrorDescription, SyntaxTree } from '../../utils/models'; import { IConstituenta, IRSErrorDescription, SyntaxTree } from '../../utils/models';
import { getCstExpressionPrefix } from '../../utils/staticUI'; import { getCstExpressionPrefix, getTypificationLabel } from '../../utils/staticUI';
import ParsingResult from './elements/ParsingResult'; import ParsingResult from './elements/ParsingResult';
import RSLocalButton from './elements/RSLocalButton'; import RSLocalButton from './elements/RSLocalButton';
import RSTokenButton from './elements/RSTokenButton'; import RSTokenButton from './elements/RSTokenButton';
@ -17,6 +17,7 @@ import { getSymbolSubstitute, TextWrapper } from './elements/textEditing';
interface EditorRSExpressionProps { interface EditorRSExpressionProps {
id: string id: string
activeCst?: IConstituenta
label: string label: string
isActive: boolean isActive: boolean
disabled?: boolean disabled?: boolean
@ -30,10 +31,10 @@ interface EditorRSExpressionProps {
} }
function EditorRSExpression({ function EditorRSExpression({
id, label, disabled, isActive, placeholder, value, setValue, onShowAST, id, activeCst, label, disabled, isActive, placeholder, value, setValue, onShowAST,
toggleEditMode, setTypification, onChange toggleEditMode, setTypification, onChange
}: EditorRSExpressionProps) { }: EditorRSExpressionProps) {
const { schema, activeCst } = useRSForm(); const { schema } = useRSForm();
const [isModified, setIsModified] = useState(false); const [isModified, setIsModified] = useState(false);
const { parseData, checkExpression, resetParse, loading } = useCheckExpression({ schema }); const { parseData, checkExpression, resetParse, loading } = useCheckExpression({ schema });
const expressionCtrl = useRef<HTMLTextAreaElement>(null); const expressionCtrl = useRef<HTMLTextAreaElement>(null);
@ -66,7 +67,11 @@ function EditorRSExpression({
} }
expressionCtrl.current!.focus(); expressionCtrl.current!.focus();
setIsModified(false); setIsModified(false);
setTypification(parse.typification); setTypification(getTypificationLabel({
isValid: parse.parseResult,
resultType: parse.typification,
args: parse.args
}));
}); });
} }
@ -205,13 +210,22 @@ function EditorRSExpression({
return ( return (
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3 w-full'> <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 <Label
text={label} text={label}
required={false} required={false}
htmlFor={id} htmlFor={id}
/> />
<textarea id={id} ref={expressionCtrl} <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} rows={6}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
@ -223,23 +237,15 @@ function EditorRSExpression({
/> />
<div className='flex w-full gap-4 py-1 mt-1 justify-stretch'> <div className='flex w-full gap-4 py-1 mt-1 justify-stretch'>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
{isActive && <StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>}
<Button <Button
tooltip='Проверить формальное выражение' tooltip='Проверить формальное выражение'
text='Проверить' text='Проверить'
widthClass='h-full w-fit'
colorClass='clr-btn-default'
onClick={handleCheckExpression} onClick={handleCheckExpression}
/> />
</div> </div>
{isActive && EditButtons} {isActive && EditButtons}
{!isActive && <StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>}
</div> </div>
{ (loading || parseData) && { (loading || parseData) &&
<div className='w-full overflow-y-auto border mt-2 max-h-[14rem] min-h-[7rem]'> <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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, useSelection } from 'reagraph'; import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, Sphere, useSelection } from 'reagraph';
import Button from '../../components/Common/Button'; import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox'; import Checkbox from '../../components/Common/Checkbox';
import ConceptSelect from '../../components/Common/ConceptSelect'; import ConceptSelect from '../../components/Common/ConceptSelect';
import { ArrowsRotateIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext'; import { useConceptTheme } from '../../context/ThemeContext';
import useLocalStorage from '../../hooks/useLocalStorage'; import useLocalStorage from '../../hooks/useLocalStorage';
import { resources } from '../../utils/constants'; import { resources } from '../../utils/constants';
import { Graph } from '../../utils/Graph';
import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI'; import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI';
function EditorTermGraph() { function EditorTermGraph() {
const { schema } = useRSForm(); const { schema } = useRSForm();
const { darkMode } = useConceptTheme(); const { darkMode, noNavigation } = useConceptTheme();
const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'forceatlas2'); const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'treeTd2d');
const [ filtered, setFiltered ] = useState<Graph>(new Graph());
const [ orbit, setOrbit ] = useState(false); 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); 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(() => { const nodes: GraphNode[] = useMemo(() => {
return schema?.items.map(cst => { const result: GraphNode[] = [];
return { if (!schema) {
id: String(cst.id), return result;
label: (cst.term.resolved || cst.term.raw) ? `${cst.alias}: ${cst.term.resolved || cst.term.raw}` : cst.alias }
}} filtered.nodes.forEach(node => {
) ?? []; const cst = schema.items.find(cst => cst.id === node.id);
}, [schema?.items]); 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 edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = []; const result: GraphEdge[] = [];
let edgeID = 1; let edgeID = 1;
schema?.graph.nodes.forEach(source => { filtered.nodes.forEach(source => {
source.outputs.forEach(target => { source.outputs.forEach(target => {
result.push({ result.push({
id: String(edgeID), id: String(edgeID),
@ -40,7 +69,7 @@ function EditorTermGraph() {
}); });
}); });
return result; return result;
}, [schema?.graph]); }, [filtered.nodes]);
const handleCenter = useCallback(() => { const handleCenter = useCallback(() => {
graphRef.current?.resetControls(); graphRef.current?.resetControls();
@ -62,29 +91,47 @@ function EditorTermGraph() {
focusOnSelect: false 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 (<> return (<>
<div className='relative w-full'> <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'>
<ConceptSelect <div className='flex items-center gap-1 w-[15rem]'>
options={GraphLayoutSelector} <Button
placeholder='Выберите тип' icon={<ArrowsRotateIcon size={8} />}
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []} dense
onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }} 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 <Checkbox
label='Анимация вращения' label='Анимация вращения'
widthClass='w-full'
value={orbit} value={orbit}
onChange={ event => setOrbit(event.target.checked) }/> onChange={ event => setOrbit(event.target.checked) }
<Button />
text='Центрировать' <Checkbox
dense label='Удалить несвязанные'
onClick={handleCenter} value={noHermits}
onChange={ event => setNoHermits(event.target.checked) }
/>
<Checkbox
label='Транзитивная редукция'
value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) }
/> />
</div> </div>
</div> </div>
<div className='flex-wrap w-full h-full overflow-auto'> <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 <GraphCanvas
draggable draggable
ref={graphRef} ref={graphRef}
@ -99,9 +146,12 @@ function EditorTermGraph() {
onNodePointerOver={onNodePointerOver} onNodePointerOver={onNodePointerOver}
onNodePointerOut={onNodePointerOut} onNodePointerOut={onNodePointerOut}
cameraMode={ orbit ? 'orbit' : layout.includes('3d') ? 'rotate' : 'pan'} 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} labelFontUrl={resources.graph_font}
theme={darkMode ? darkTheme : lightTheme} theme={darkMode ? darkTheme : lightTheme}
renderNode={({ node, ...rest }) => (
<Sphere {...rest} node={node} />
)}
/> />
</div> </div>
</div> </div>

View File

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

View File

@ -1,6 +1,6 @@
import ConceptTooltip from '../../../components/Common/ConceptTooltip'; import ConceptTooltip from '../../../components/Common/ConceptTooltip';
import { IConstituenta } from '../../../utils/models'; import { IConstituenta } from '../../../utils/models';
import { getTypeLabel } from '../../../utils/staticUI'; import { getCstTypificationLabel } from '../../../utils/staticUI';
interface ConstituentaTooltipProps { interface ConstituentaTooltipProps {
data: IConstituenta data: IConstituenta
@ -14,7 +14,7 @@ function ConstituentaTooltip({ data, anchor }: ConstituentaTooltipProps) {
className='max-w-[25rem] min-w-[25rem]' className='max-w-[25rem] min-w-[25rem]'
> >
<h1>Конституента {data.alias}</h1> <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> <p><b>Термин: </b>{data.term.resolved || data.term.raw}</p>
{data.definition.formal && <p><b>Выражение: </b>{data.definition.formal}</p>} {data.definition.formal && <p><b>Выражение: </b>{data.definition.formal}</p>}
{data.definition.text.resolved && <p><b>Определение: </b>{data.definition.text.resolved}</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 Divider from '../../../components/Common/Divider';
import LabeledText from '../../../components/Common/LabeledText'; import LabeledText from '../../../components/Common/LabeledText';
import { type IRSFormStats } from '../../../utils/models'; import { type IRSFormStats } from '../../../utils/models';
@ -9,7 +8,7 @@ interface RSFormStatsProps {
function RSFormStats({ stats }: RSFormStatsProps) { function RSFormStats({ stats }: RSFormStatsProps) {
return ( return (
<Card> <div className='px-4 py-2 border'>
<LabeledText id='count_all' <LabeledText id='count_all'
label='Всего конституент ' label='Всего конституент '
text={stats.count_all} text={stats.count_all}
@ -28,12 +27,12 @@ function RSFormStats({ stats }: RSFormStatsProps) {
label='Невычислимы ' label='Невычислимы '
text={stats.count_incalc} text={stats.count_incalc}
/>} />}
<Divider /> <Divider margins='my-1' />
<LabeledText id='count_termin' <LabeledText id='count_termin'
label='Термины ' label='Термины '
text={stats.count_termin} text={stats.count_termin}
/> />
<Divider /> <Divider margins='my-1' />
{ stats.count_base > 0 && { stats.count_base > 0 &&
<LabeledText id='count_base' <LabeledText id='count_base'
label='Базисные множества ' label='Базисные множества '
@ -74,7 +73,7 @@ function RSFormStats({ stats }: RSFormStatsProps) {
label='Теормы ' label='Теормы '
text={stats.count_theorem} text={stats.count_theorem}
/>} />}
</Card> </div>
); );
} }

View File

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

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import Checkbox from '../../../components/Common/Checkbox';
import ConceptDataTable from '../../../components/Common/ConceptDataTable'; import ConceptDataTable from '../../../components/Common/ConceptDataTable';
import { useRSForm } from '../../../context/RSFormContext'; import { useRSForm } from '../../../context/RSFormContext';
import { useConceptTheme } from '../../../context/ThemeContext'; import { useConceptTheme } from '../../../context/ThemeContext';
import useLocalStorage from '../../../hooks/useLocalStorage'; import useLocalStorage from '../../../hooks/useLocalStorage';
import { prefixes } from '../../../utils/constants'; 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 { getCstDescription, getMockConstituenta, mapStatusInfo } from '../../../utils/staticUI';
import ConstituentaTooltip from './ConstituentaTooltip'; import ConstituentaTooltip from './ConstituentaTooltip';
import DependencyModePicker from './DependencyModePicker';
import MatchModePicker from './MatchModePicker';
interface ViewSideConstituentsProps { interface ViewSideConstituentsProps {
expression: string expression: string
@ -19,31 +20,38 @@ interface ViewSideConstituentsProps {
function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideConstituentsProps) { function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideConstituentsProps) {
const { darkMode } = useConceptTheme(); const { darkMode } = useConceptTheme();
const { schema } = useRSForm(); 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 [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '')
const [onlyExpression, setOnlyExpression] = useLocalStorage('side-filter-flag', false);
useEffect(() => { useEffect(() => {
if (!schema?.items) { if (!schema?.items) {
setFilteredData([]); setFilteredData([]);
return; return;
} }
if (onlyExpression) { let filtered: IConstituenta[] = [];
if (filterSource === DependencyMode.EXPRESSION) {
const aliases = extractGlobals(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 names = filtered.map(cst => cst.alias)
const diff = Array.from(aliases).filter(name => !names.includes(name)); const diff = Array.from(aliases).filter(name => !names.includes(name));
if (diff.length > 0) { if (diff.length > 0) {
diff.forEach( diff.forEach(
(alias, index) => filtered.push(getMockConstituenta(-index, alias, CstType.BASE, 'Конституента отсутствует'))); (alias, index) => filtered.push(getMockConstituenta(-index, alias, CstType.BASE, 'Конституента отсутствует')));
} }
setFilteredData(filtered); } else if (!activeID) {
} else if (!filterText) { filtered = schema.items
setFilteredData(schema?.items);
} else { } 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( const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => { (cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
@ -130,25 +138,19 @@ function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideCons
} }
], [] ], []
); );
return ( return (
<div className='max-h-[80vh] overflow-y-scroll border flex-grow 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-2 border-gray-400 rounded dark:bg-gray-700 dark:border-gray-300'> <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='w-full'> <div className='flex items-center justify-between w-full'>
<MatchModePicker value={filterMatch} onChange={setFilterMatch}/>
<input type='text' <input type='text'
className='w-full px-2 outline-none dark:bg-gray-700 hover:text-clip' className='w-full px-2 bg-white outline-none hover:text-clip clr-bg-pop clr-border'
placeholder='текст для фильтрации списка' placeholder='наберите текст фильтра'
value={filterText} value={filterText}
onChange={event => { setFilterText(event.target.value); }} 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>
</div> </div>
<ConceptDataTable <ConceptDataTable

View File

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

View File

@ -1,8 +1,6 @@
import InfoMessage from '../components/InfoMessage';
function RestorePasswordPage() { function RestorePasswordPage() {
return ( 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.keys()]).toStrictEqual([1, 2, 3, 4]);
expect([... graph.nodes.get(1)!.outputs]).toStrictEqual([2]); 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', () => { describe('Testing Graph queries', () => {

View File

@ -10,6 +10,13 @@ export class GraphNode {
this.inputs = []; this.inputs = [];
} }
clone(): GraphNode {
const result = new GraphNode(this.id);
result.outputs = [... this.outputs];
result.inputs = [... this.inputs];
return result;
}
addOutput(node: number): void { addOutput(node: number): void {
this.outputs.push(node); 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 { addNode(target: number): GraphNode {
let node = this.nodes.get(target); let node = this.nodes.get(target);
if (!node) { if (!node) {
@ -67,6 +80,16 @@ export class Graph {
return nodeToRemove; 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 { addEdge(source: number, destination: number): void {
const sourceNode = this.addNode(source); const sourceNode = this.addNode(source);
const destinationNode = this.addNode(destination); 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[] { expandOutputs(origin: number[]): number[] {
const result: number[] = []; const result: number[] = [];
const marked = new Map<number, boolean>(); const marked = new Map<number, boolean>();
@ -143,27 +174,63 @@ export class Graph {
return result; return result;
} }
visitDFS(visitor: (node: GraphNode) => void) { tolopogicalOrder(): number[] {
const visited: Map<number, boolean> = new Map(); const result: number[] = [];
const marked = new Map<number, boolean>();
this.nodes.forEach(node => { this.nodes.forEach(node => {
if (!visited.has(node.id)) { if (marked.get(node.id)) {
this.depthFirstSearch(node, visited, visitor); 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( transitiveReduction() {
node: GraphNode, const order = this.tolopogicalOrder();
visited: Map<number, boolean>, const marked = new Map<number, boolean>();
visitor: (node: GraphNode) => void) order.forEach(nodeID => {
: void { if (marked.get(nodeID)) {
visited.set(node.id, true); return;
visitor(node); }
node.outputs.forEach((item) => { const stack: {id: number, parents: number[]}[] = [];
if (!visited.has(item)) { stack.push({id: nodeID, parents: []});
const childNode = this.nodes.get(item)!; while (stack.length > 0) {
this.depthFirstSearch(childNode, visited, visitor); 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 // Constants
const prod = { 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 = { const dev = {
@ -16,7 +18,8 @@ export const urls = {
exteor64: 'https://drive.google.com/open?id=1IJt25ZRQ-ZMA6t7hOqmo5cv05WJCQKMv&usp=drive_fs', exteor64: 'https://drive.google.com/open?id=1IJt25ZRQ-ZMA6t7hOqmo5cv05WJCQKMv&usp=drive_fs',
ponomarev: 'https://inponomarev.ru/textbook', ponomarev: 'https://inponomarev.ru/textbook',
intro_video: 'https://www.youtube.com/watch?v=0Ty9mu9sOJo', 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 = { export const resources = {

View File

@ -69,6 +69,11 @@ export interface ISyntaxTreeNode {
} }
export type SyntaxTree = ISyntaxTreeNode[] export type SyntaxTree = ISyntaxTreeNode[]
export interface IFunctionArg {
alias: string
typification: string
}
export interface IExpressionParse { export interface IExpressionParse {
parseResult: boolean parseResult: boolean
syntax: Syntax syntax: Syntax
@ -77,6 +82,7 @@ export interface IExpressionParse {
errors: IRSErrorDescription[] errors: IRSErrorDescription[]
astText: string astText: string
ast: SyntaxTree ast: SyntaxTree
args: IFunctionArg[]
} }
export interface IRSExpression { export interface IRSExpression {
@ -118,6 +124,7 @@ export interface IConstituenta {
valueClass: ValueClass valueClass: ValueClass
typification: string typification: string
syntaxTree: string syntaxTree: string
args: IFunctionArg[]
} }
} }
@ -231,6 +238,25 @@ export enum ExpressionStatus {
VERIFIED 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 ================= // ========== Model functions =================
export function inferStatus(parse?: ParsingStatus, value?: ValueClass): ExpressionStatus { export function inferStatus(parse?: ParsingStatus, value?: ValueClass): ExpressionStatus {
if (!parse || !value) { if (!parse || !value) {
@ -322,22 +348,23 @@ export function LoadRSFormData(schema: IRSFormData): IRSForm {
return result; return result;
} }
export function matchConstituenta(query: string, target?: IConstituenta) { export function matchConstituenta(query: string, target: IConstituenta, mode: CstMatchMode) {
if (!target) { if ((mode === CstMatchMode.ALL || mode === CstMatchMode.NAME) &&
return false; target.alias.match(query)) {
} else if (target.alias.match(query)) {
return true; 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) { export function matchRSFormMeta(query: string, target: IRSFormMeta) {
@ -350,3 +377,21 @@ export function matchRSFormMeta(query: string, target: IRSFormMeta) {
return false; 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 { LayoutTypes } from 'reagraph';
import { resolveErrorClass,RSErrorClass, RSErrorType, TokenID } from './enums'; 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 { export interface IRSButtonData {
text: string text: string
@ -14,16 +17,6 @@ export interface IStatusInfo {
tooltip: string 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 { export function getCstDescription(cst: IConstituenta): string {
if (cst.cstType === CstType.STRUCTURED) { if (cst.cstType === CstType.STRUCTURED) {
return ( return (
@ -273,6 +266,27 @@ export const mapLayoutLabels: Map<string, string> = new Map([
['nooverlap', 'Без перекрытия'] ['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}[] = [ export const GraphLayoutSelector: {value: LayoutTypes, label: string}[] = [
{ value: 'forceatlas2', label: 'Атлас 2D'}, { value: 'forceatlas2', label: 'Атлас 2D'},
{ value: 'forceDirected2d', label: 'Силы 2D'}, { value: 'forceDirected2d', label: 'Силы 2D'},
@ -360,7 +374,8 @@ export function getMockConstituenta(id: number, alias: string, type: CstType, co
status: ParsingStatus.INCORRECT, status: ParsingStatus.INCORRECT,
valueClass: ValueClass.INVALID, valueClass: ValueClass.INVALID,
typification: 'N/A', 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 { export function getRSErrorPrefix(error: IRSErrorDescription): string {
const id = error.errorType.toString(16) const id = error.errorType.toString(16)
switch(resolveErrorClass(error.errorType)) { 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) { export async function delay(ms: number) {
return await new Promise(resolve => setTimeout(resolve, ms)); 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 react from '@vitejs/plugin-react';
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({