diff --git a/.env b/.env index 6dcde31..44bdc5b 100755 --- a/.env +++ b/.env @@ -20,4 +20,6 @@ POSTGRES_USER=postgres_user POSTGRES_PASSWORD=postgres_password POSTGRES_DB=users POSTGRES_HOST=postgresql -POSTGRES_PORT=5432 \ No newline at end of file +POSTGRES_PORT=5432 + +SENTRY_DSN= \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b3cc54..1683b0e 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,6 @@ repos: rev: stable hooks: - id: black - language_version: python3.7 + language_version: python3.8 default_stages: [commit, push] \ No newline at end of file diff --git a/README.md b/README.md index fc7e006..1bba263 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Nostradamus also calculates various statistical data including distributions and * a list of the most frequently used terms; * a list of the most significant words, etc. -This knowledge further allows to achieve various IT-related goals, e.g.: +This knowledge further allows achieving various IT-related goals, e.g.: * 📝 More accurate planning and goal setting for Project Managers; * 📈 Improving the defect report quality for QA Engineers and Junior Analysts; * 🔎 Discovering the dependencies hidden in development, for system architects and developers. diff --git a/auth/Dockerfile b/auth/Dockerfile new file mode 100755 index 0000000..3f07b88 --- /dev/null +++ b/auth/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.8-slim + +WORKDIR /app + +USER root + +RUN apt-get -y update && apt-get install -y libpq-dev gcc \ + && pip install --upgrade pip + +RUN pip3 install poetry==1.1.4 + +COPY poetry.lock pyproject.toml /app/ + +RUN poetry install --no-dev + +COPY . /app/ diff --git a/nostradamus/apps/authentication/main/__init__.py b/auth/authentication/__init__.py similarity index 100% rename from nostradamus/apps/authentication/main/__init__.py rename to auth/authentication/__init__.py diff --git a/auth/authentication/register.py b/auth/authentication/register.py new file mode 100644 index 0000000..ea2d362 --- /dev/null +++ b/auth/authentication/register.py @@ -0,0 +1,30 @@ +from passlib.context import CryptContext + +from models.User import User +from models.UserSettings import UserSettings +from models.UserFilter import UserFilter +from models.UserQAMetricsFilter import UserQAMetricsFilter +from models.UserPredictionsTable import UserPredictionsTable + +from serializers import UserSerializer + +from settings.settings import init_filters, init_predictions_table + + +async def create_user(user: UserSerializer) -> None: + """Creates a new User with default settings. + + :param user: New user. + """ + user = user.dict() + user["password"] = CryptContext(schemes=["sha256_crypt"]).hash( + user["password"] + ) + user["email"] = user["email"].lower().strip() + user["name"] = user["name"].strip() + user = await User(**user).create() + user_settings = await UserSettings(user_id=user.id).create() + + await init_filters(UserFilter, user_settings.id) + await init_filters(UserQAMetricsFilter, user_settings.id) + await init_predictions_table(UserPredictionsTable, user_settings.id) diff --git a/auth/authentication/sign_in.py b/auth/authentication/sign_in.py new file mode 100644 index 0000000..5370a5c --- /dev/null +++ b/auth/authentication/sign_in.py @@ -0,0 +1,59 @@ +import json +from typing import Dict, Union + + +from fastapi import HTTPException +from passlib.context import CryptContext + +from authentication.token import create_token +from models.User import User + +from serializers import UserCredentialsSerializer +from database import create_session + + +DEFAULT_ERROR_CODE = 500 + + +def auth_user( + user_data: UserCredentialsSerializer, +) -> Dict[str, Union[str, int]]: + """Authenticates User. + + :param user_data: User credentials. + :return: Authenticated User with JWT. + """ + with create_session() as db: + user = ( + db.query(User) + .filter(User.email == user_data.credentials.lower().strip()) + .first() + ) + if not user: + user = ( + db.query(User) + .filter( + User.name == user_data.credentials.strip(), + ) + .first() + ) + if not user: + raise HTTPException( + status_code=DEFAULT_ERROR_CODE, + detail="Incorrect username or password.", + ) + if not CryptContext(schemes=["sha256_crypt"]).verify( + user_data.password, user.password + ): + raise HTTPException( + status_code=DEFAULT_ERROR_CODE, + detail="Incorrect username or password.", + ) + + response = { + "id": str(user.id), + "name": user.name, + "email": user.email, + "token": create_token(user), + } + return response diff --git a/auth/authentication/token.py b/auth/authentication/token.py new file mode 100644 index 0000000..61bb1c6 --- /dev/null +++ b/auth/authentication/token.py @@ -0,0 +1,52 @@ +import os + +import jwt +from datetime import datetime, timedelta +from fastapi import HTTPException + +from database import create_session +from models.User import User + +ACCESS_TOKEN_EXPIRE_WEEKS = 15 + +SECRET_KEY_DEFAULT = "v$1bx=+6#ibt4a$4i&5i8stwjqzm+3=tjsde9iku1a0w(u6bfy" +SECRET_KEY = os.environ.get("SECRET_KEY", default=SECRET_KEY_DEFAULT) + + +def create_token(user: User) -> str: + """Generates JSON Web Token. + + :param user: User to be authenticated. + :return: JSON Web Token. + """ + raw_token = { + "exp": datetime.utcnow() + timedelta(weeks=ACCESS_TOKEN_EXPIRE_WEEKS), + "id": str(user.id), + } + token = jwt.encode(raw_token, key=SECRET_KEY).decode("UTF-8") + return token + + +def decode_jwt(token: str) -> str: + """Checks JSON Web Token and return user id. + + :param token: JSON Web Token. + :return: User id. + """ + # To avoid a circular dependency + from authentication.sign_in import DEFAULT_ERROR_CODE + + try: + decoded_token = jwt.decode(jwt=token, key=SECRET_KEY) + except jwt.DecodeError: + raise HTTPException(status_code=DEFAULT_ERROR_CODE, detail="Token is invalid") + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=DEFAULT_ERROR_CODE, detail="Token expired") + with create_session() as db: + user = db.query(User).filter(User.id == decoded_token.get("id")).first() + + if not user: + raise HTTPException( + status_code=DEFAULT_ERROR_CODE, detail="User not found", + ) + return str(user.id) diff --git a/auth/database.py b/auth/database.py new file mode 100644 index 0000000..16320d9 --- /dev/null +++ b/auth/database.py @@ -0,0 +1,32 @@ +import os +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session + + +DB_NAME = os.environ.get("POSTGRES_DB", "users") +DB_USER = os.environ.get("POSTGRES_USER", "postgres_user") +DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres_password") +DB_HOST = os.environ.get("POSTGRES_HOST", "localhost") +DB_PORT = os.environ.get("POSTGRES_PORT", "5432") +DB_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +engine = create_engine(DB_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +@contextmanager +def create_session() -> Session: + """Create database session. + + :return: Database session. + """ + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/auth/main.py b/auth/main.py new file mode 100644 index 0000000..3e2a696 --- /dev/null +++ b/auth/main.py @@ -0,0 +1,69 @@ +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError, HTTPException +from fastapi.responses import JSONResponse +from starlette.requests import Request +from starlette.responses import Response + +from authentication.token import decode_jwt +from authentication.sign_in import auth_user +from authentication.register import create_user + +from database import engine, Base + +from serializers import ( + UserSerializer, + UserCredentialsSerializer, + AuthResponseSerializer, + VerifyTokenResponse, +) + + +TABLES_TO_CREATE = ( + Base.metadata.tables["users"], + Base.metadata.tables["user_settings"], + Base.metadata.tables["user_filter"], + Base.metadata.tables["user_qa_metrics_filter"], + Base.metadata.tables["user_predictions_table"], +) + +Base.metadata.create_all(bind=engine, tables=TABLES_TO_CREATE) + +app = FastAPI() + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request, exc): + return JSONResponse( + content={"exception": {"detail": exc.detail}}, status_code=exc.status_code, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + exception_detail = [ + {"name": error.get("loc")[1], "errors": [error.get("msg")]} + for error in exc.errors() + ] + + return JSONResponse( + status_code=400, content=jsonable_encoder({"exception": exception_detail}), + ) + + +@app.post("/sign_in/", response_model=AuthResponseSerializer) +def sign_in(user: UserCredentialsSerializer): + user = auth_user(user) + return JSONResponse(user) + + +@app.post("/register/") +async def register(user: UserSerializer): + await create_user(user) + return Response(status_code=200) + + +@app.get("/verify_token/", response_model=VerifyTokenResponse) +def verify_token(token: str): + response = {"id": decode_jwt(token)} + return JSONResponse(response) diff --git a/auth/models/User.py b/auth/models/User.py new file mode 100644 index 0000000..812b16c --- /dev/null +++ b/auth/models/User.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, String, Integer +from sqlalchemy.orm import relationship + +from database import Base, create_session + + +class User(Base): + __tablename__ = "users" + + id = Column( + Integer, primary_key=True, index=True, unique=True, autoincrement=True + ) + email = Column(String, unique=True) + name = Column( + String, + unique=True, + ) + password = Column(String) + + user_settings = relationship( + "UserSettings", + uselist=False, + back_populates="user", + cascade="all, delete, delete-orphan", + ) + + def __repr__(self): + return ( + f"" + ) + + async def create(self): + with create_session() as db: + db.add(self) + db.commit() + db.refresh(self) + return self diff --git a/auth/models/UserFilter.py b/auth/models/UserFilter.py new file mode 100644 index 0000000..a476247 --- /dev/null +++ b/auth/models/UserFilter.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, ForeignKey, String, Integer +from sqlalchemy.orm import relationship + +from database import Base, create_session + + +class UserFilter(Base): + __tablename__ = "user_filter" + + id = Column( + Integer, primary_key=True, index=True, unique=True, autoincrement=True + ) + settings_id = Column( + Integer, ForeignKey("user_settings.id", ondelete="CASCADE") + ) + name = Column(String) + type = Column(String) + + user_settings = relationship("UserSettings", back_populates="user_filter") + + def __repr__(self): + return ( + f"" + ) + + async def create(self): + with create_session() as db: + db.add(self) + db.commit() + db.refresh(self) + return self diff --git a/auth/models/UserPredictionsTable.py b/auth/models/UserPredictionsTable.py new file mode 100644 index 0000000..ead4255 --- /dev/null +++ b/auth/models/UserPredictionsTable.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, Boolean +from sqlalchemy.orm import relationship + +from database import Base, create_session + + +class UserPredictionsTable(Base): + __tablename__ = "user_predictions_table" + + id = Column( + Integer, primary_key=True, index=True, unique=True, autoincrement=True + ) + settings_id = Column( + Integer, ForeignKey("user_settings.id", ondelete="CASCADE") + ) + name = Column(String) + is_default = Column(Boolean) + position = Column(Integer) + + user_settings = relationship( + "UserSettings", back_populates="user_predictions_table" + ) + + def __repr__(self): + return ( + f"" + ) + + async def create(self): + with create_session() as db: + db.add(self) + db.commit() + db.refresh(self) + return self diff --git a/auth/models/UserQAMetricsFilter.py b/auth/models/UserQAMetricsFilter.py new file mode 100644 index 0000000..b7b08ae --- /dev/null +++ b/auth/models/UserQAMetricsFilter.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, ForeignKey, String, Integer +from sqlalchemy.orm import relationship + +from database import Base, create_session + + +class UserQAMetricsFilter(Base): + __tablename__ = "user_qa_metrics_filter" + + id = Column( + Integer, primary_key=True, index=True, unique=True, autoincrement=True + ) + settings_id = Column( + Integer, ForeignKey("user_settings.id", ondelete="CASCADE") + ) + name = Column(String) + type = Column(String) + + user_settings = relationship( + "UserSettings", back_populates="user_qa_metrics_filter" + ) + + def __repr__(self): + return ( + f"" + ) + + async def create(self): + with create_session() as db: + db.add(self) + db.commit() + db.refresh(self) + return self diff --git a/auth/models/UserSettings.py b/auth/models/UserSettings.py new file mode 100644 index 0000000..d360786 --- /dev/null +++ b/auth/models/UserSettings.py @@ -0,0 +1,40 @@ +from sqlalchemy import Column, ForeignKey, Integer +from sqlalchemy.orm import relationship + +from database import Base, create_session + + +class UserSettings(Base): + __tablename__ = "user_settings" + + id = Column( + Integer, primary_key=True, index=True, unique=True, autoincrement=True + ) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + + user = relationship("User", back_populates="user_settings") + user_filter = relationship( + "UserFilter", + back_populates="user_settings", + cascade="all, delete, delete-orphan", + ) + user_qa_metrics_filter = relationship( + "UserQAMetricsFilter", + back_populates="user_settings", + cascade="all, delete, delete-orphan", + ) + user_predictions_table = relationship( + "UserPredictionsTable", + back_populates="user_settings", + cascade="all, delete, delete-orphan", + ) + + def __repr__(self): + return f"" + + async def create(self): + with create_session() as db: + db.add(self) + db.commit() + db.refresh(self) + return self diff --git a/nostradamus/apps/authentication/migrations/__init__.py b/auth/models/__init__.py similarity index 100% rename from nostradamus/apps/authentication/migrations/__init__.py rename to auth/models/__init__.py diff --git a/auth/poetry.lock b/auth/poetry.lock new file mode 100644 index 0000000..0f9dc8b --- /dev/null +++ b/auth/poetry.lock @@ -0,0 +1,762 @@ +[[package]] +name = "aiounittest" +version = "1.4.0" +description = "Test asyncio code more easily." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +wrapt = "*" + +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "20.3.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +name = "black" +version = "20.8b1" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +appdirs = "*" +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.6,<1" +regex = ">=2020.1.8" +toml = ">=0.10.1" +typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +name = "certifi" +version = "2020.12.5" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "fastapi" +version = "0.62.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pydantic = ">=1.0.0,<2.0.0" +starlette = "0.13.6" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "aiofiles (>=0.5.0,<0.6.0)", "jinja2 (>=2.11.2,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<2.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "graphene (>=2.1.8,<3.0.0)", "ujson (>=3.0.0,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn (>=0.11.5,<0.12.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)"] +dev = ["python-jose[cryptography] (>=3.1.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn (>=0.11.5,<0.12.0)", "graphene (>=2.1.8,<3.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=6.1.4,<7.0.0)", "markdown-include (>=0.5.1,<0.6.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.2.0)", "typer (>=0.3.0,<0.4.0)", "typer-cli (>=0.0.9,<0.0.10)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (==5.4.3)", "pytest-cov (==2.10.0)", "pytest-asyncio (>=0.14.0,<0.15.0)", "mypy (==0.782)", "flake8 (>=3.8.3,<4.0.0)", "black (==19.10b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.15.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.4.0)", "orjson (>=3.2.1,<4.0.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "aiofiles (>=0.5.0,<0.6.0)", "flask (>=1.1.2,<2.0.0)"] + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[[package]] +name = "h11" +version = "0.11.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "3.3.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "20.8" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"] +totp = ["cryptography"] + +[[package]] +name = "pathspec" +version = "0.8.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "psycopg2-binary" +version = "2.8.6" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydantic" +version = "1.7.3" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyjwt" +version = "1.7.1" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +crypto = ["cryptography (>=1.4)"] +flake8 = ["flake8", "flake8-import-order", "pep8-naming"] +test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"] + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.2.1" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "regex" +version = "2020.11.13" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.25.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<5" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + +[[package]] +name = "sqlalchemy" +version = "1.3.21" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mysql = ["mysqlclient"] +oracle = ["cx-oracle"] +postgresql = ["psycopg2"] +postgresql_pg8000 = ["pg8000"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql"] + +[[package]] +name = "starlette" +version = "0.13.6" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "typed-ast" +version = "1.4.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvicorn" +version = "0.13.1" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +click = ">=7.0.0,<8.0.0" +h11 = ">=0.8" + +[package.extras] +standard = ["websockets (>=8.0.0,<9.0.0)", "watchgod (>=0.6,<0.7)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "httptools (>=0.1.0,<0.2.0)", "uvloop (>=0.14.0)", "colorama (>=0.4)"] + +[[package]] +name = "wrapt" +version = "1.12.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.4.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.8" +content-hash = "008c217b3692fb7c4d7dbe1188929777fc219570bbc7f064278d038ad0e32055" + +[metadata.files] +aiounittest = [ + {file = "aiounittest-1.4.0-py3-none-any.whl", hash = "sha256:c47c5a87f5bba67ee7a0d593422083266db0800aea48705b768c4e3f51d9ed4a"}, + {file = "aiounittest-1.4.0.tar.gz", hash = "sha256:5bd6b507a0df4f3497340fce3f6d41b8e558f5c0ad266efc7cebe5bc41c6211b"}, +] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, +] +black = [ + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, +] +certifi = [ + {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, + {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, +] +chardet = [ + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +fastapi = [ + {file = "fastapi-0.62.0-py3-none-any.whl", hash = "sha256:62074dd38541d9d7245f3aacbbd0d44340c53d56186c9b249d261a18dad4874b"}, + {file = "fastapi-0.62.0.tar.gz", hash = "sha256:8f4c64cd9cea67fb7dd175ca5015961efa572b9f43a8731014dac8929d86225f"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] +h11 = [ + {file = "h11-0.11.0-py2.py3-none-any.whl", hash = "sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"}, + {file = "h11-0.11.0.tar.gz", hash = "sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab"}, +] +idna = [ + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, +] +importlib-metadata = [ + {file = "importlib_metadata-3.3.0-py3-none-any.whl", hash = "sha256:bf792d480abbd5eda85794e4afb09dd538393f7d6e6ffef6e9f03d2014cf9450"}, + {file = "importlib_metadata-3.3.0.tar.gz", hash = "sha256:5c5a2720817414a6c41f0a49993908068243ae02c1635a228126519b509c8aed"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, + {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, +] +passlib = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] +pathspec = [ + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win32.whl", hash = "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win_amd64.whl", hash = "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win32.whl", hash = "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win_amd64.whl", hash = "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win32.whl", hash = "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win_amd64.whl", hash = "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win32.whl", hash = "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win_amd64.whl", hash = "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win32.whl", hash = "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win32.whl", hash = "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6"}, +] +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pydantic = [ + {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a4143c8d0c456a093387b96e0f5ee941a950992904d88bc816b4f0e72c9a0009"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d8df4b9090b595511906fa48deda47af04e7d092318bfb291f4d45dfb6bb2127"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:514b473d264671a5c672dfb28bdfe1bf1afd390f6b206aa2ec9fed7fc592c48e"}, + {file = "pydantic-1.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:dba5c1f0a3aeea5083e75db9660935da90216f8a81b6d68e67f54e135ed5eb23"}, + {file = "pydantic-1.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59e45f3b694b05a69032a0d603c32d453a23f0de80844fb14d55ab0c6c78ff2f"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b24e8a572e4b4c18f614004dda8c9f2c07328cb5b6e314d6e1bbd536cb1a6c1"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:b2b054d095b6431cdda2f852a6d2f0fdec77686b305c57961b4c5dd6d863bf3c"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f"}, + {file = "pydantic-1.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:6e3874aa7e8babd37b40c4504e3a94cc2023696ced5a0500949f3347664ff8e2"}, + {file = "pydantic-1.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e682f6442ebe4e50cb5e1cfde7dda6766fb586631c3e5569f6aa1951fd1a76ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:f5b06f5099e163295b8ff5b1b71132ecf5866cc6e7f586d78d7d3fd6e8084608"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:24ca47365be2a5a3cc3f4a26dcc755bcdc9f0036f55dcedbd55663662ba145ec"}, + {file = "pydantic-1.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:d1fe3f0df8ac0f3a9792666c69a7cd70530f329036426d06b4f899c025aca74e"}, + {file = "pydantic-1.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6864844b039805add62ebe8a8c676286340ba0c6d043ae5dea24114b82a319e"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ecb54491f98544c12c66ff3d15e701612fc388161fd455242447083350904730"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8d72e814c7821125b16f1553124d12faba88e85405b0864328899aceaad7282b"}, + {file = "pydantic-1.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:475f2fa134cf272d6631072554f845d0630907fce053926ff634cc6bc45bf1af"}, + {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"}, + {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pyjwt = [ + {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"}, + {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, + {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, +] +regex = [ + {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, + {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, + {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, + {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, + {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, + {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, + {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, + {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, + {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, + {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, + {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, + {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, + {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, +] +requests = [ + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, +] +sqlalchemy = [ + {file = "SQLAlchemy-1.3.21-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:61bb5c71837845ee31c0cbe87b3c7f92652dbeafa10295f45610ea6bf349d887"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f84c06915c752e25068151b834b3470307317a73ddae8b9def034face0e7ef37"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4329ca33a1c266ec9910ca697821f2a712d34089092fad9f9666ea193c5e02fa"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27m-win32.whl", hash = "sha256:c3651d8023a9bba79c9d2c80c745237fd7ee77f001bd6c9aab9350024f0e8d01"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27m-win_amd64.whl", hash = "sha256:20fd664489567d23eb049a0441ddc057cba46f704bb1980c2ac0c6a47a9c32c7"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6b3e85d513a3d59437047c262ff99d801f5727f6a25497aa6880da44f33d0170"}, + {file = "SQLAlchemy-1.3.21-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d45170c234e0294a2a46cdc46b487ccb708aaf927f54da1862c75d723f494e5e"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:d6090f68ba8db34864f9befd3aab60e60642443c57a65b781d43f4088514e4c6"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f462b59addafcf69570164bdfa1e7f44653653ae571b7964fad50132b8c1b608"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:0f851547a28a4c2bd5b7fc6d05a306b9460d6f7a256989af11d8ddcdc386cc46"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:b25856479c240e0ecf28562d3c4cf1b4786ad2c0a7825b9d67c1db6293156bae"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-win32.whl", hash = "sha256:7a1387fe4cd491122b49af565fb833b90dd1ecf360dd76173b75253981bea5bb"}, + {file = "SQLAlchemy-1.3.21-cp35-cp35m-win_amd64.whl", hash = "sha256:3c024c191e019bd673fe48cdf3bca1215f971bfd189838841903fa12ef206fb4"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:7478f45f9b3cf2a2cb8808fd8ffe436e92f7332d4da1f4e8c559d5602189ac4b"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:00d377c3fc069ba615091dee73b90446388091123a8d976c24376eb1044cba6f"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:16de3aad992eddbce3204832947bafd92b5de50364aea353cd21127132cee2ca"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:dc87984befa099d42f1cd5ab9e298864af1acc568932716c33ba78dde8241a01"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-win32.whl", hash = "sha256:8952188c690e521ee2a33a7ff2b878a5dc475e389d85085e585104d819f2d8b1"}, + {file = "SQLAlchemy-1.3.21-cp36-cp36m-win_amd64.whl", hash = "sha256:0f7b310fd84cf81d49c9aa1fb5eaeba2d16c490e8d3969586cd1eb8e4aedefbf"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:de707a9aa9395d44d427793ea77403698f9193b9fc7d81139c03f7239d88c4ae"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8eef062f9dacc32b4d498a2822ce5a61badb041b202c448e829c253051d24ef0"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c08baad28cb37dd35fa4c227fc4c312b1f65f7bb19a557995e160cce80b58b2c"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4346ffc0a8756bfd8db4679c9bb52564f74fe1ffd60e6899db06823f111dbd9c"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-win32.whl", hash = "sha256:4a128f45f404d78bbb0a629cc58dd090bdfded37e568c4016cf2252585eb018a"}, + {file = "SQLAlchemy-1.3.21-cp37-cp37m-win_amd64.whl", hash = "sha256:3f4c7463703030470f2af3e988681ab2fa08270282fc55ede448aed0854ca8ba"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:87b8314132081da1d3aa46dcd7b7a6ce7dc76cf7941471e659f740138a725a07"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:4d35478f0dd39eb3439f5970cc2c8396bb5c4881c4f8e3900ba041b4103ed86b"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:bac445ed686fc0be02f6b4d64e5702ea5b4eef9849a7ad16522b2eea95d1dd3a"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d366bc4d0655a7926733e1b29258cc3c876b8520b61923e4838954eb0e3ec290"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-win32.whl", hash = "sha256:be4ac1036512db122964e4a41dc9bc08815ed772e37ac2a481fa825f58fcf5ee"}, + {file = "SQLAlchemy-1.3.21-cp38-cp38-win_amd64.whl", hash = "sha256:574e4da65e2f9cf083b24e34c10db2aeae8a7642628ef2c0e26ff202c1fd58f0"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:24c1dbc7089dc88fba12f1fdd0ea42f57d111a54a9071c745264c00e73152fe8"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94dbaf31729e2108050351b830b9f304bfa4838f8c60981b0b46cf257e528f17"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561a6a3e799c59c6221e4e50deb18bc97434b480fb4a68da3623b609fb38f428"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:dff61e81cbabdb4dd6dee421ae8842b3191468a602e909e23940890f553f7ac3"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-win32.whl", hash = "sha256:40c56eba051c504f85ea6cbc2cb4ec2bb83f06e8479041075a16b35f0bae3400"}, + {file = "SQLAlchemy-1.3.21-cp39-cp39-win_amd64.whl", hash = "sha256:cf4977686e92f59cb965959dd2076e1a4c06f37ebb263a9674721aaec02684d4"}, + {file = "SQLAlchemy-1.3.21.tar.gz", hash = "sha256:0bc49cba55b01b6827d1c303486da1afaaaf65a7a4d0e2be2cbc31c0f56752dc"}, +] +starlette = [ + {file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, + {file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, + {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, + {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] +urllib3 = [ + {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, + {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, +] +uvicorn = [ + {file = "uvicorn-0.13.1-py3-none-any.whl", hash = "sha256:6fcce74c00b77d4f4b3ed7ba1b2a370d27133bfdb46f835b7a76dfe0a8c110ae"}, + {file = "uvicorn-0.13.1.tar.gz", hash = "sha256:2a7b17f4d9848d6557ccc2274a5f7c97f1daf037d130a0c6918f67cd9bc8cdf5"}, +] +wrapt = [ + {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, +] +zipp = [ + {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, + {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, +] diff --git a/auth/pyproject.toml b/auth/pyproject.toml new file mode 100644 index 0000000..a9da426 --- /dev/null +++ b/auth/pyproject.toml @@ -0,0 +1,29 @@ +[tool.poetry] +name = "auth" +version = "0.1.0" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.8" +fastapi = "^0.62.0" +PyJWT = "^1.7.1" +psycopg2-binary = "^2.8.6" +passlib = "^1.7.4" +requests = "^2.25.0" +SQLAlchemy = "^1.3.20" +uvicorn = "^0.13.1" +pytest = "^6.2.0" +aiounittest = "^1.4.0" +importlib-metadata = "^3.3.0" + +[tool.poetry.scripts] +auth-service = "uvicorn main:app --reload --host 0.0.0.0 --port 8080" + +[tool.poetry.dev-dependencies] +black = "^20.8b1" +flake8 = "^3.8.4" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/auth/serializers.py b/auth/serializers.py new file mode 100644 index 0000000..7fcc10b --- /dev/null +++ b/auth/serializers.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, validator + +from validators.email import validate_email_pattern, validate_email_uniqueness +from validators.name import validate_name_pattern, validate_name_uniqueness +from validators.validators import ( + validate_for_whitespace, + validate_symbols_count, +) + + +class UserSerializer(BaseModel): + email: str + name: str + password: str + + @validator("name",) + def validate_name(cls, name: str): + validate_for_whitespace(name, "username") + validate_symbols_count(string=name, maximum=64, field_name="user") + validate_name_uniqueness(name) + validate_name_pattern(name) + return name + + @validator("email") + def validate_email(cls, email: str): + email = email.lower() + validate_for_whitespace(email, "email") + validate_symbols_count(string=email, maximum=254, field_name="email") + validate_email_uniqueness(email) + validate_email_pattern(email) + return email + + @validator("password") + def validate_password(cls, password: str): + validate_symbols_count( + string=password, minimum=6, maximum=254, field_name="password" + ) + return password + + +class UserCredentialsSerializer(BaseModel): + credentials: str + password: str + + +class AuthResponseSerializer(BaseModel): + id: str + name: str + email: str + token: str + + +class VerifyTokenResponse(BaseModel): + id: str diff --git a/nostradamus/apps/extractor/migrations/__init__.py b/auth/settings/__init__.py similarity index 100% rename from nostradamus/apps/extractor/migrations/__init__.py rename to auth/settings/__init__.py diff --git a/auth/settings/settings.py b/auth/settings/settings.py new file mode 100644 index 0000000..f3c3692 --- /dev/null +++ b/auth/settings/settings.py @@ -0,0 +1,56 @@ +from database import Base + +DEFAULT_FILTERS = [ + {"name": "Project", "type": "drop-down"}, + {"name": "Attachments", "type": "numeric"}, + {"name": "Priority", "type": "drop-down"}, + {"name": "Resolved", "type": "date"}, + {"name": "Labels", "type": "string"}, + {"name": "Created", "type": "date"}, + {"name": "Comments", "type": "numeric"}, + {"name": "Status", "type": "drop-down"}, + {"name": "Key", "type": "drop-down"}, + {"name": "Summary", "type": "string"}, + {"name": "Resolution", "type": "drop-down"}, + {"name": "Description", "type": "string"}, + {"name": "Components", "type": "string"}, +] + +DEFAULT_PREDICTIONS_TABLE_COLUMNS = [ + "Issue Key", + "Priority", + "Area of Testing", + "Time to Resolve", + "Summary", +] + + +async def init_filters(model: Base, settings_id: int) -> None: + """Creates a default filters settings. + + :param model: Metaclass of table. + :param settings_id: User settings id. + """ + + for filter_ in DEFAULT_FILTERS: + await model( + name=filter_["name"], + type=filter_["type"], + settings_id=settings_id, + ).create() + + +async def init_predictions_table(model: Base, settings_id: int) -> None: + """Creates a default predictions table settings. + + :param model: Metaclass of table. + :param settings_id: User settings id. + """ + + for position, name in enumerate(DEFAULT_PREDICTIONS_TABLE_COLUMNS, 1): + await model( + name=name, + is_default=True, + position=position, + settings_id=settings_id, + ).create() diff --git a/nostradamus/apps/qa_metrics/migrations/__init__.py b/auth/tests/__init__.py similarity index 100% rename from nostradamus/apps/qa_metrics/migrations/__init__.py rename to auth/tests/__init__.py diff --git a/auth/tests/conftest.py b/auth/tests/conftest.py new file mode 100644 index 0000000..f5173d4 --- /dev/null +++ b/auth/tests/conftest.py @@ -0,0 +1,49 @@ +import pytest + +from string import ascii_lowercase +from random import choice + +from models.User import User + + +@pytest.fixture(scope="class") +def host(request): + host = "http://localhost:8080/" + request.cls.host = host + + +@pytest.fixture(scope="class") +def register_url(request): + request.cls.register_url = "register/" + + +@pytest.fixture(scope="class") +def signin_url(request): + request.cls.signin_url = "sign_in/" + + +@pytest.fixture(scope="class") +def verify_token_url(request): + request.cls.verify_token_url = "verify_token/" + + +@pytest.fixture(scope="class") +def test_user_1(request): + request.cls.test_user_1 = { + "name": "".join(choice(ascii_lowercase) for _ in range(20)), + "email": f"{''.join(choice(ascii_lowercase) for _ in range(20))}@test.com", + "password": 123456, + } + + +@pytest.fixture(scope="class") +def test_user_2(request): + request.cls.test_user_2 = { + "name": "".join(choice(ascii_lowercase) for _ in range(20)), + "email": f"{''.join(choice(ascii_lowercase) for _ in range(20))}@test.com", + "password": 123456, + } + +@pytest.fixture(scope="class") +def test_user_model(request): + request.cls.test_user_model = User(id=100500, email="test@gmail.com", name="test", password="123456") diff --git a/auth/tests/requests_tests/__init__.py b/auth/tests/requests_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/tests/requests_tests/test_register.py b/auth/tests/requests_tests/test_register.py new file mode 100644 index 0000000..659a59e --- /dev/null +++ b/auth/tests/requests_tests/test_register.py @@ -0,0 +1,50 @@ +import requests +import unittest +import pytest + +from database import create_session +from models.User import User + + +@pytest.mark.usefixtures( + "test_user_1", + "test_user_2", + "host", + "register_url", +) +class TestRegister(unittest.TestCase): + def teardown_method(self, _): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + db.query(User).filter(User.name == self.test_user_2["name"]).delete() + + def test_register_success(self): + """Test request for register success.""" + request = requests.post(self.host + self.register_url, json=self.test_user_1) + + assert request.status_code == 200 + + def test_registered_already(self): + """Test request that user registered already.""" + requests.post(self.host + self.register_url, json=self.test_user_2) + request = requests.post(self.host + self.register_url, json=self.test_user_2) + + assert ( + request.status_code == 400 + and request.json()["exception"][0]["errors"][0] == "Email already taken." + ) + + def test_check_registered_user(self): + """Test that registered user exist in database.""" + requests.post(self.host + self.register_url, json=self.test_user_2) + with create_session() as db: + result = ( + db.query(User) + .filter( + User.name == self.test_user_2["name"], + User.email == self.test_user_2["email"], + ) + .first() + ) + + assert result is not None diff --git a/auth/tests/requests_tests/test_signin.py b/auth/tests/requests_tests/test_signin.py new file mode 100644 index 0000000..6050826 --- /dev/null +++ b/auth/tests/requests_tests/test_signin.py @@ -0,0 +1,115 @@ +import requests +import unittest +import pytest + +from database import create_session +from models.User import User + + +@pytest.mark.usefixtures( + "test_user_1", + "host", + "signin_url", + "register_url", + "verify_token_url", +) +class TestRegister(unittest.TestCase): + def teardown_method(self, method): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + + def test_auth_by_username(self): + """Test request for checking auth by username.""" + requests.post(self.host + self.register_url, json=self.test_user_1).json() + + test_user = { + "credentials": self.test_user_1["name"], + "password": self.test_user_1["password"], + } + + request = requests.post(self.host + self.signin_url, json=test_user) + data = request.json() + + assert data is not None and "exception" not in data + + def test_auth_by_email(self): + """Test request for checking auth by email.""" + requests.post(self.host + self.register_url, json=self.test_user_1) + + test_user = { + "credentials": self.test_user_1["email"], + "password": self.test_user_1["password"], + } + + request = requests.post(self.host + self.signin_url, json=test_user) + data = request.json() + + assert data is not None and "exception" not in data + + def test_auth_error(self): + """Test request for checking auth error.""" + requests.post(self.host + self.register_url, json=self.test_user_1).json() + + test_user = { + "credentials": self.test_user_1["email"], + "password": "1234Pass", + } + + request = requests.post(self.host + self.signin_url, json=test_user) + data = request.json() + + assert ( + data is not None + and data["exception"]["detail"] == "Incorrect username or password." + ) + + def test_auth_data(self): + """Test request for validation data from response.""" + requests.post(self.host + self.register_url, json=self.test_user_1).json() + + test_user = { + "credentials": self.test_user_1["name"], + "password": self.test_user_1["password"], + } + + with create_session() as db: + user_id = ( + db.query(User) + .filter(User.email == self.test_user_1["email"]) + .first() + .id + ) + + request = requests.post(self.host + self.signin_url, json=test_user) + data = request.json() + + assert all( + [ + data["id"] == str(user_id), + data["name"] == self.test_user_1["name"], + data["email"] == self.test_user_1["email"], + ] + ) + + def test_auth_token(self): + """Test request for validate token.""" + requests.post( + self.host + self.register_url, + json=self.test_user_1, + ).json() + + test_user = { + "credentials": self.test_user_1["name"], + "password": self.test_user_1["password"], + } + + request = requests.post(self.host + self.signin_url, json=test_user) + + user_id = request.json()["id"] + token = request.json()["token"] + + request = requests.get( + self.host + self.verify_token_url, params={"token": token} + ) + + assert request.json()["id"] == user_id diff --git a/auth/tests/requests_tests/test_validate_register.py b/auth/tests/requests_tests/test_validate_register.py new file mode 100644 index 0000000..1c16ec4 --- /dev/null +++ b/auth/tests/requests_tests/test_validate_register.py @@ -0,0 +1,76 @@ +import requests +import unittest +import pytest + +from database import create_session +from models.User import User + + +@pytest.mark.usefixtures( + "test_user_1", + "host", + "register_url", +) +class TestRegister(unittest.TestCase): + def teardown_method(self, _): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + + def test_special_symbols_of_name(self): + """Test request for checking special symbols in a field name.""" + test_user = self.test_user_1.copy() + test_user.update({"name": "test#$%user", "email": "test.user1@test.com"}) + + request = requests.post(self.host + self.register_url, json=test_user) + assert ( + request.json()["exception"][0]["name"] == "name" + and request.json()["exception"][0]["errors"][0] + == "Ensure that name doesn't have special symbols." + ) + + def test_special_symbols_of_email(self): + """Test request for checking special symbols in a field email.""" + test_user = self.test_user_1.copy() + test_user.update({"name": "test.user", "email": "test.u#ser1@test.com"}) + + request = requests.post(self.host + self.register_url, json=test_user) + assert ( + request.json()["exception"][0]["name"] == "email" + and request.json()["exception"][0]["errors"][0] + == "Ensure that email doesn't have special characters." + ) + + def test_password_length(self): + """Test request for checking password length.""" + test_user = self.test_user_1.copy() + test_user.update( + { + "name": "test.user.2", + "email": "test.user.2@test.com", + "password": 123, + } + ) + request = requests.post(self.host + self.register_url, json=test_user) + + assert ( + request.status_code == 400 + and request.json()["exception"][0]["errors"][0] + == "Ensure password cannot be less than 6 symbol(s)." + ) + + def test_whitespaces_credentials(self): + """Test request for checking whitespaces in field.""" + test_user = self.test_user_1.copy() + test_user.update( + { + "name": "test.user 3", + "email": "test.user3@test.com", + } + ) + request = requests.post(self.host + self.register_url, json=test_user) + + assert ( + request.json()["exception"][0]["name"] == "name" + and request.json()["exception"][0]["errors"][0] + == "Username cannot contain whitespaces." + ) diff --git a/auth/tests/token_tests/__init__.py b/auth/tests/token_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/tests/token_tests/test_token.py b/auth/tests/token_tests/test_token.py new file mode 100644 index 0000000..c15a627 --- /dev/null +++ b/auth/tests/token_tests/test_token.py @@ -0,0 +1,54 @@ +import aiounittest +import pytest +from fastapi import HTTPException + +from authentication.register import create_user +from authentication.token import create_token, decode_jwt +from database import create_session +from models.User import User +from serializers import UserSerializer + + +@pytest.mark.usefixtures( + "test_user_1", + "test_user_model" +) +class TestRegister(aiounittest.AsyncTestCase): + def teardown_method(self, _): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + + def test_create_token(self): + """Test for validate create token.""" + token = create_token(self.test_user_model) + + assert token + + def test_decode_token_user_not_found(self): + """Negative test for validate decode token with error 'user_not_found'.""" + token = create_token(self.test_user_model) + with pytest.raises(HTTPException) as exception_info: + decode_jwt(token) + + assert exception_info + + def test_decode_token_invalid(self): + """Negative test for validate decode invalid token.""" + token = "adbcdeerf123" + with pytest.raises(HTTPException) as exception_info: + decode_jwt(token) + + assert exception_info + + async def test_decode_token_positive(self): + """Positive test for validate decode token.""" + user = UserSerializer(**self.test_user_1) + await create_user(user) + + with create_session() as db: + user = db.query(User).filter(User.name == self.test_user_1["name"]).first() + + token = create_token(user) + user_id = decode_jwt(token) + + assert user_id \ No newline at end of file diff --git a/auth/tests/validators_tests/__init__.py b/auth/tests/validators_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/tests/validators_tests/test_email.py b/auth/tests/validators_tests/test_email.py new file mode 100644 index 0000000..092e295 --- /dev/null +++ b/auth/tests/validators_tests/test_email.py @@ -0,0 +1,46 @@ +import aiounittest +import pytest + +from authentication.register import create_user +from database import create_session +from models.User import User +from serializers import UserSerializer +from validators.email import validate_email_pattern, validate_email_uniqueness + + +@pytest.mark.usefixtures( + "test_user_1", +) +class TestRegister(aiounittest.AsyncTestCase): + def teardown_method(self, _): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + + def test_email_pattern_negative(self): + """Negative test of validate email pattern.""" + email = "#test%test%@gmail.com" + with pytest.raises(ValueError) as exception_info: + validate_email_pattern(email) + + assert "Ensure that email doesn't have special characters." == str(exception_info.value) + + def test_email_pattern_positive(self): + """Positive test of validate email pattern.""" + email = "test@gmail.com" + validate_email_pattern(email) + + def test_email_uniqueness_positive(self): + """Positive test of validate email uniqueness.""" + email = "test@gmail.com" + validate_email_uniqueness(email) + + async def test_email_uniqueness_negative(self): + """Negative test of validate email uniqueness.""" + user = UserSerializer(**self.test_user_1) + await create_user(user) + + email = self.test_user_1["email"] + with pytest.raises(ValueError) as exception_info: + validate_email_uniqueness(email) + + assert "Email already taken." == str(exception_info.value) diff --git a/auth/tests/validators_tests/test_name.py b/auth/tests/validators_tests/test_name.py new file mode 100644 index 0000000..c2f7262 --- /dev/null +++ b/auth/tests/validators_tests/test_name.py @@ -0,0 +1,47 @@ +import aiounittest +import pytest + +from authentication.register import create_user +from database import create_session +from models.User import User +from serializers import UserSerializer +from validators.name import validate_name_pattern, validate_name_uniqueness + + +@pytest.mark.usefixtures( + "test_user_1", +) +class TestRegister(aiounittest.AsyncTestCase): + def teardown_method(self, _): + with create_session() as db: + db.query(User).filter(User.name == self.test_user_1["name"]).delete() + + def test_name_pattern_negative(self): + """Negative test of validate name pattern.""" + name = "#Big%Test%" + + with pytest.raises(ValueError) as exception_info: + validate_name_pattern(name) + + assert "Ensure that name doesn't have special symbols." == str(exception_info.value) + + def test_name_pattern_positive(self): + """Positive test of validate name pattern.""" + name = "BigTest" + validate_name_pattern(name) + + def test_name_uniqueness_positive(self): + """Positive test of validate name uniqueness.""" + name = "test" + validate_name_uniqueness(name) + + async def test_name_uniqueness_negative(self): + """Negative test of validate name uniquness.""" + user = UserSerializer(**self.test_user_1) + await create_user(user) + + name = self.test_user_1["name"] + with pytest.raises(ValueError) as exception_info: + validate_name_uniqueness(name) + + assert "Name already taken." == str(exception_info.value) diff --git a/auth/tests/validators_tests/test_validators.py b/auth/tests/validators_tests/test_validators.py new file mode 100644 index 0000000..1e36a3c --- /dev/null +++ b/auth/tests/validators_tests/test_validators.py @@ -0,0 +1,42 @@ +import pytest + +from validators.validators import validate_symbols_count, validate_for_whitespace + + +def test_validate_max_symbols(): + """Negative test of validate max symbols in string.""" + string = "abcdefgh123456" + with pytest.raises(ValueError) as exception_info: + validate_symbols_count(string, "password", 10) + + assert "Ensure password cannot be longer than 10 symbols." == str(exception_info.value) + + +def test_validate_min_symbols(): + """Negative test of validate min symbols in string.""" + string = "12345" + with pytest.raises(ValueError) as exception_info: + validate_symbols_count(string, "password", 256, 6) + + assert "Ensure password cannot be less than 6 symbol(s)." == str(exception_info.value) + + +def test_validate_symbols_positive(): + """Positive test of validate symbols in string.""" + string = "123456789" + validate_symbols_count(string, "password", 256, 6) + + +def test_validate_whitespace_negative(): + """Negative test of validate whitespace in string.""" + string = "test test test" + with pytest.raises(ValueError) as exception_info: + validate_for_whitespace(string, "name") + + assert "Name cannot contain whitespaces." == str(exception_info.value) + + +def test_validate_whitespace_positive(): + """Positive test of validate whitespace in string.""" + string = "test_test_test" + validate_for_whitespace(string, "name") \ No newline at end of file diff --git a/auth/validators/__init__.py b/auth/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/validators/email.py b/auth/validators/email.py new file mode 100644 index 0000000..5ee722e --- /dev/null +++ b/auth/validators/email.py @@ -0,0 +1,24 @@ +import re + +from database import create_session +from models.User import User + + +def validate_email_uniqueness(email: str) -> None: + """Validates the email for uniqueness. + + :param email: Email to be validated. + """ + with create_session() as db: + if db.query(User).filter(User.email == email).first(): + raise ValueError("Email already taken.") + + +def validate_email_pattern(email: str) -> None: + """Validate the email for special characters. + + :param email: Email to be validated. + """ + forbidden_symbols = re.compile(r"[№/!--:-?[-`{-~]") + if forbidden_symbols.search(email): + raise ValueError("Ensure that email doesn't have special characters.") diff --git a/auth/validators/name.py b/auth/validators/name.py new file mode 100644 index 0000000..1c59e91 --- /dev/null +++ b/auth/validators/name.py @@ -0,0 +1,24 @@ +import re + +from database import create_session +from models.User import User + + +def validate_name_pattern(name: str) -> None: + """Validate the name for special characters. + + :param name: Name to be validated. + """ + forbidden_symbols = re.compile(r"[-№`/!-,:-@[-^{-~]") + if forbidden_symbols.search(name): + raise ValueError("Ensure that name doesn't have special symbols.") + + +def validate_name_uniqueness(name: str) -> None: + """Validates the name for uniqueness. + + :param name: Name to be validated. + """ + with create_session() as db: + if db.query(User).filter(User.name == name).first(): + raise ValueError("Name already taken.") diff --git a/auth/validators/validators.py b/auth/validators/validators.py new file mode 100644 index 0000000..81e1bd7 --- /dev/null +++ b/auth/validators/validators.py @@ -0,0 +1,35 @@ +import re + + +def validate_symbols_count( + string: str, + field_name: str, + maximum: int, + minimum: int = 0, +) -> None: + """Validates the field string for minimum and maximum length. + + :param string: String to be validated. + :param field_name: Field name. + :param maximum: Maximum length. + :param minimum: Minimum length. + """ + if len(string) > maximum: + raise ValueError( + f"Ensure {field_name} cannot be longer than {maximum} symbols." + ) + if len(string) < minimum: + raise ValueError( + f"Ensure {field_name} cannot be less than {minimum} symbol(s)." + ) + + +def validate_for_whitespace(string: str, field_name: str) -> None: + """Validates the string for containing whitespaces. + + :param string: String to be validated. + :param field_name: Name of the field under validation. + """ + pattern = re.compile(r"\s") + if pattern.search(string): + raise ValueError(f"{field_name.capitalize()} cannot contain whitespaces.") diff --git a/chatbot/README.md b/chatbot/README.md new file mode 100644 index 0000000..8706b31 --- /dev/null +++ b/chatbot/README.md @@ -0,0 +1,3 @@ +# Astra + +Virtual Assistant for Nostradamus App \ No newline at end of file diff --git a/chatbot/actions/responses.py b/chatbot/actions/responses.py index 5236b21..5c043ff 100644 --- a/chatbot/actions/responses.py +++ b/chatbot/actions/responses.py @@ -13,11 +13,11 @@ ], "ask_filter_string_type": [ "This filtration type does substring search. It can be applied if a value you're looking for is too long.", - "There is also an exact-match flag with two options:\n* ON - full-match search;\n* OFF - substring search.", + "There is also an exact-match flag with two options:\n* ON - full-match search;\n* OFF - substring search.\n", ], "ask_filter_dropdown_type": [ "This filtration type can be used for fields containing repetitive values like Priority, Resolution, etc.\nThe required elements can be easily chosen from the list.", - "There is also an exact-match flag with two options:\n* ON - full-match search, which means that all the chosen elements have to be listed in the record;\n* OFF - at least one chosen element has to be listed on a record.", + "There is also an exact-match flag with two options:\n* ON - full-match search, which means that all the chosen elements have to be listed in the record;\n* OFF - at least one chosen element has to be listed on a record.\n", ], "ask_filter_date_type": [ "This filtration type can be applied to date fields.\nYou can easily choose a single date or even a specific range of dates." @@ -55,9 +55,9 @@ ], "ask_training": [ "Training gives me magical abilities!🧙‍♂️", - "With training, I can learn on the bugs you uploaded to predict:\n* How long it takes to fix a bug\n* What final decision will be made for a bug\n* What area of testing a bug belongs to\n* And even what priority level a bug will have when submitted!", + "With training, I can learn on the bugs you uploaded to predict:\n* How long it takes to fix a bug\n* What final decision will be made for a bug\n* What area of testing a bug belongs to\n* And even what priority level a bug will have when submitted!\n", "The more bugs you give the more accurate my predictions are! 💪🏽", - 'Training is also the process of creating so-called "models":\n* Priority;\n* Time to Resolve;\n* A model for each area of testing;\n* A model for each resolution.', + 'Training is also the process of creating so-called "models":\n* Priority;\n* Time to Resolve;\n* A model for each area of testing;\n* A model for each resolution.\n', ], "ask_training_purpose": [ "Training enables me to produce predictions.\nThey are my superpower 😎", diff --git a/docker-compose.yml b/docker-compose.yml index 8907676..e25280b 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,23 +18,48 @@ services: depends_on: - redis - postgresql + ports: + - "8000:8000" nostradamus-frontend: container_name: nostradamus-frontend + restart: "always" build: context: ./frontend dockerfile: Dockerfile - ports: - - '80:80' - - '443:8443' links: - nostradamus-core - flower - volumes: - - ./certs:/opt/nginx/certs environment: - SERVER_NAME + proxy: + container_name: proxy + restart: "on-failure" + build: + context: ./proxy + dockerfile: Dockerfile + ports: + - mode: host + protocol: tcp + published: 80 + target: 80 + - mode: host + protocol: tcp + published: 443 + target: 8443 + depends_on: + - nostradamus-core + - nostradamus-frontend + - channels + - virtual-assistant-core + volumes: + - ./certs:/opt/nginx/certs +# - ./proxy/nginx/nginx.conf:/etc/nginx/nginx.conf +# - ./proxy/nginx/conf.d:/etc/nginx/conf.d + environment: + - SERVER_NAME + redis: container_name: redis restart: always @@ -110,22 +135,26 @@ services: virtual-assistant-core: container_name: virtual-assistant-core image: rasa/rasa:1.10.3-full - ports: - - 5005:5005 + # ports: + # - 5005:5005 build: context: ./chatbot dockerfile: Dockerfile-core command: run --enable-api --cors '*' --debug env_file: *envfile + restart: "on-failure" virtual-assistant-actions: container_name: virtual-assistant-actions build: context: ./chatbot dockerfile: Dockerfile-actions - expose: - - "5055" + # expose: + # - "5055" env_file: *envfile + restart: "on-failure" + +# CHECK: postgres volume postgresql: container_name: postgresql @@ -136,5 +165,39 @@ services: ports: - 5432:5432 + auth: + container_name: auth + build: + context: ./auth + dockerfile: Dockerfile + # ports: + # - "8080:8080" + env_file: *envfile + command: "poetry run uvicorn main:app --reload --host 0.0.0.0 --port 8080" + links: + - postgresql + depends_on: + - postgresql + restart: "on-failure" + + ml-core: + container_name: ml-core + build: + context: ./ml-core + dockerfile: Dockerfile + # ports: + # - "8282:8282" + env_file: *envfile + command: "poetry run uvicorn main:app --reload --host 0.0.0.0 --port 8282" + links: + - postgresql + - mongodb + - redis + depends_on: + - postgresql + - mongodb + - redis + restart: "on-failure" + volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1617fe6..212fa96 100755 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,6 +22,5 @@ RUN apk add --no-cache bash \ COPY --from=build /src/app/build /usr/share/nginx/html COPY nginx/nginx.conf /etc/nginx/nginx.conf COPY nginx/conf.d/* /etc/nginx/conf.d/ -RUN mkdir -p /opt/nginx/certs COPY frontend-entrypoint.sh . ENTRYPOINT ["bash", "frontend-entrypoint.sh"] diff --git a/frontend/cypress/fixtures/at-page/empty-filters.json b/frontend/cypress/fixtures/at-page/empty-filters.json index 3377b60..4096483 100644 --- a/frontend/cypress/fixtures/at-page/empty-filters.json +++ b/frontend/cypress/fixtures/at-page/empty-filters.json @@ -1,7 +1,7 @@ [ { "name": "Project", - "filtration_type": "drop-down", + "type": "drop-down", "values": [ "Abdera", "Apache Flex", @@ -52,31 +52,31 @@ }, { "name": "Attachments", - "filtration_type": "numeric", + "type": "numeric", "current_value": [], "exact_match": false }, { "name": "Priority", - "filtration_type": "drop-down", + "type": "drop-down", "values": ["Trivial", "Blocker", "Unfilled", "Critical", "Major", "Minor"], "current_value": [], "exact_match": false }, - { "name": "Resolved", "filtration_type": "date", "current_value": [], "exact_match": false }, - { "name": "Labels", "filtration_type": "string", "current_value": [], "exact_match": false }, - { "name": "Created", "filtration_type": "date", "current_value": [], "exact_match": false }, - { "name": "Comments", "filtration_type": "numeric", "current_value": [], "exact_match": false }, + { "name": "Resolved", "type": "date", "current_value": [], "exact_match": false }, + { "name": "Labels", "type": "string", "current_value": [], "exact_match": false }, + { "name": "Created", "type": "date", "current_value": [], "exact_match": false }, + { "name": "Comments", "type": "numeric", "current_value": [], "exact_match": false }, { "name": "Status", - "filtration_type": "drop-down", + "type": "drop-down", "values": ["Resolved", "In Progress", "Open", "Closed", "Patch Available", "Reopened"], "current_value": [], "exact_match": false }, { "name": "Key", - "filtration_type": "drop-down", + "type": "drop-down", "values": [ "AMQ-4315", "ASTERIXDB-1646", @@ -55255,10 +55255,10 @@ "current_value": [], "exact_match": false }, - { "name": "Summary", "filtration_type": "string", "current_value": [], "exact_match": false }, + { "name": "Summary", "type": "string", "current_value": [], "exact_match": false }, { "name": "Resolution", - "filtration_type": "drop-down", + "type": "drop-down", "values": [ "Not A Bug", "Workaround", @@ -55288,6 +55288,6 @@ "current_value": [], "exact_match": false }, - { "name": "Description", "filtration_type": "string", "current_value": [], "exact_match": false }, - { "name": "Components", "filtration_type": "string", "current_value": [], "exact_match": false } + { "name": "Description", "type": "string", "current_value": [], "exact_match": false }, + { "name": "Components", "type": "string", "current_value": [], "exact_match": false } ] diff --git a/frontend/frontend-entrypoint.sh b/frontend/frontend-entrypoint.sh index 95b2d9f..27d8360 100755 --- a/frontend/frontend-entrypoint.sh +++ b/frontend/frontend-entrypoint.sh @@ -1,19 +1,4 @@ #!/bin/bash #bash frontend-entrypoint.sh - -KEYFILE=/opt/nginx/certs/key.key -CRTFILE=/opt/nginx/certs/cert.crt - -if [[ ! -f "$KEYFILE" ]]; then - echo "$KEYFILE does not exist. Starting nginx without ssl" - rm /etc/nginx/conf.d/ssl_*.conf -elif [[ ! -f "$CRTFILE" ]]; then - echo "$CRTFILE does not exist. Starting nginx without ssl" - rm /etc/nginx/conf.d/ssl_*.conf -else - echo "$KEYFILE & $CRTFILE do exist. Starting nginx with ssl" -fi - -envsubst '$SERVER_NAME' < /etc/nginx/conf.d/reverse_80.nginx.template > /etc/nginx/conf.d/reverse_80.nginx - +envsubst '$SERVER_NAME' < /etc/nginx/conf.d/static.nginx.template > /etc/nginx/conf.d/static.nginx nginx -g "daemon off;" diff --git a/frontend/integration-test-coverage.md b/frontend/integration-test-coverage.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/nginx/conf.d/static.nginx.template b/frontend/nginx/conf.d/static.nginx.template new file mode 100644 index 0000000..3989f2e --- /dev/null +++ b/frontend/nginx/conf.d/static.nginx.template @@ -0,0 +1,13 @@ +server { + listen 80; + server_name '${SERVER_NAME}'; +# access_log /dev/stdout/ combined; +# error_log /dev/stderr/; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + add_header Access-Control-Allow-Origin "*"; + } +} diff --git a/frontend/nginx/nginx.conf b/frontend/nginx/nginx.conf index 6428d27..ec1dd5e 100644 --- a/frontend/nginx/nginx.conf +++ b/frontend/nginx/nginx.conf @@ -15,8 +15,8 @@ http { include mime.types; default_type application/octet-stream; - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log info; +# access_log /dev/stdout/; +# error_log /dev/stderr/; sendfile on; server_tokens off; diff --git a/frontend/src/app/common/api/analysis-and-training.api.ts b/frontend/src/app/common/api/analysis-and-training.api.ts index 5c3342a..3267a0b 100644 --- a/frontend/src/app/common/api/analysis-and-training.api.ts +++ b/frontend/src/app/common/api/analysis-and-training.api.ts @@ -12,7 +12,7 @@ import { ToastStyle } from "app/modules/toasts-overlay/store/types"; export class AnalysisAndTrainingApi { static baseUrl = "analysis_and_training"; - public static async getTotalStatistic(): Promise<{ records_count?: MainStatisticData }> { + public static async getTotalStatistic(): Promise { try { return await HttpClient.get(`${this.baseUrl}/`); } catch (e) { @@ -43,15 +43,13 @@ export class AnalysisAndTrainingApi { try { return await HttpClient.get(`${this.baseUrl}/frequently_terms/`); } catch (e) { - store.dispatch(addToast(e.detail, ToastStyle.Error)); throw e; } } public static async getStatistic(): Promise { try { - const request = await HttpClient.get(`${this.baseUrl}/statistics/`); - return request.statistics; + return await HttpClient.get(`${this.baseUrl}/statistics/`); } catch (e) { store.dispatch(addToast(e.detail, ToastStyle.Error)); throw e; @@ -60,10 +58,10 @@ export class AnalysisAndTrainingApi { public static async getDefectSubmission(timeFilter?: string) { try { - if (timeFilter) { - return await HttpClient.post(`${this.baseUrl}/defect_submission/`, { period: timeFilter }); - } - return await HttpClient.get(`${this.baseUrl}/defect_submission/`); + return await HttpClient.get( + `${this.baseUrl}/defect_submission/`, + timeFilter ? { period: timeFilter } : undefined + ); } catch (e) { throw e; } @@ -87,14 +85,14 @@ export class AnalysisAndTrainingApi { public static async trainModel() { try { - return await HttpClient.post(`${this.baseUrl}/train/`, {}); + return await HttpClient.post(`ml-core/train/`, {}); } catch (e) { throw e; } } - public static async getGeneralApplicationStatus() { + public static async getCollectingDataStatus() { try { return await HttpClient.get(this.baseUrl + '/status/'); } catch (e) { @@ -102,4 +100,13 @@ export class AnalysisAndTrainingApi { } } + public static async getTrainingModelStatus() { + try { + await HttpClient.get('description_assessment/'); + return true; + } catch (e) { + return false + } + } + } diff --git a/frontend/src/app/common/api/auth.api.ts b/frontend/src/app/common/api/auth.api.ts index f46a993..18aacf6 100644 --- a/frontend/src/app/common/api/auth.api.ts +++ b/frontend/src/app/common/api/auth.api.ts @@ -1,31 +1,34 @@ import HttpClient from "app/common/api/http-client"; -import { HttpError, HttpValidationError } from "app/common/types/http.types"; +import { + HttpError, + HTTPValidationError +} from "app/common/types/http.types"; import { UserSignIn, UserSignUp } from "app/common/types/user.types"; export class AuthApi { static baseUrl = "auth"; - public static async signIn(signInData: UserSignIn) { + public static async getUserId(token: string) { try { - return await HttpClient.post(`${this.baseUrl}/signin/`, null, signInData); + return await HttpClient.get(`${this.baseUrl}/verify_token/`, { token }); } catch (e) { throw new HttpError(e); } } - public static async signUp(signUpData: UserSignUp) { + public static async signIn(signInData: UserSignIn) { try { - return await HttpClient.post(`${this.baseUrl}/register/`, null, signUpData); + return await HttpClient.post(`${this.baseUrl}/sign_in/`, null, signInData); } catch (e) { - throw new HttpValidationError(e, e.fields); + throw new HttpError(e); } } - public static async getTeamList() { + public static async signUp(signUpData: UserSignUp) { try { - return await HttpClient.get(`${this.baseUrl}/register/`); + return await HttpClient.post(`${this.baseUrl}/register/`, null, signUpData); } catch (e) { - throw new HttpError(e); + throw new HTTPValidationError(e); } } } diff --git a/frontend/src/app/common/api/description-assessment.api.ts b/frontend/src/app/common/api/description-assessment.api.ts index 1010979..3b4e67f 100644 --- a/frontend/src/app/common/api/description-assessment.api.ts +++ b/frontend/src/app/common/api/description-assessment.api.ts @@ -1,4 +1,6 @@ import HttpClient from "app/common/api/http-client"; +import { createChartDataFromObject } from "app/common/functions/helper"; +import { ObjectWithUnknownFields } from "app/common/types/http.types"; import { PredictMetric } from "app/modules/predict-text/predict-text"; export class DescriptionAssessmentApi { @@ -22,7 +24,22 @@ export class DescriptionAssessmentApi { public static async predictText(text: string) { try { - return await HttpClient.post(`${this.baseUrl}/predict/`, null, { description: text }); + const res = await HttpClient.post(`${this.baseUrl}/predict/`, null, { description: text }); + + res["Time to Resolve"] = createChartDataFromObject( + res["Time to Resolve"] + ); + res.Priority = createChartDataFromObject(res.Priority); + res.resolution = Object.entries(res.resolution).map( + ([name, data]) => { + return { + name, + data: createChartDataFromObject(data as ObjectWithUnknownFields), + }; + } + ); + + return res; } catch (e) { throw e; } diff --git a/frontend/src/app/common/api/http-client.ts b/frontend/src/app/common/api/http-client.ts index 7836255..dfef8bf 100644 --- a/frontend/src/app/common/api/http-client.ts +++ b/frontend/src/app/common/api/http-client.ts @@ -38,7 +38,7 @@ class HttpClientConstructor { public set token(token: string | null) { if (token) { - this.headers.set("Authorization", `JWT ${token}`); + this.headers.set("Authorization", `${token}`); } else { this.headers.delete("Authorization"); } @@ -69,6 +69,10 @@ class HttpClientConstructor { const res: Response = await this.request(url, "GET", query, undefined, outerUrl); if (res.ok) { + if (res.status === 209) { + throw new Error((await res.json()).warning.detail); + } + return fullResponse ? res : res.json(); } throw (await res.json()).exception; @@ -78,14 +82,18 @@ class HttpClientConstructor { url: string, query?: any | null, body?: any, - outerUrl?: string, - fullResponse?: boolean + outerUrl?: string ) { const res: Response = await this.request(url, "POST", query, body, outerUrl); if (res.ok) { - return fullResponse ? res : res.json(); - } - + if (res.status === 209) { + throw new Error((await res.json()).warning.detail); + } + + const string = await res.text(); + return string === "" ? {} : JSON.parse(string); + } + throw (await res.json()).exception; } } diff --git a/frontend/src/app/common/api/qa-metrics.api.ts b/frontend/src/app/common/api/qa-metrics.api.ts index d527e78..4249c92 100644 --- a/frontend/src/app/common/api/qa-metrics.api.ts +++ b/frontend/src/app/common/api/qa-metrics.api.ts @@ -1,4 +1,6 @@ import HttpClient from "app/common/api/http-client"; +import { createChartDataFromObject } from "app/common/functions/helper"; +import { ObjectWithUnknownFields } from "app/common/types/http.types"; import { FilterFieldBase } from "app/modules/filters/field/field-type"; export default class QaMetricsApi { @@ -6,7 +8,7 @@ export default class QaMetricsApi { public static async getCount(): Promise { try { - return await HttpClient.get(`${this.baseUrl}/`, undefined, undefined, true); + return await HttpClient.get(`${this.baseUrl}/`); } catch (e) { throw e; } @@ -14,7 +16,7 @@ export default class QaMetricsApi { public static async getFilters(): Promise { try { - return await HttpClient.get(`${this.baseUrl}/filter/`, undefined, undefined, true); + return await HttpClient.get(`${this.baseUrl}/filter/`); } catch (e) { throw e; } @@ -22,13 +24,7 @@ export default class QaMetricsApi { public static async saveFilters(filters: FilterFieldBase[]): Promise { try { - return await HttpClient.post( - `${this.baseUrl}/filter/`, - undefined, - { filters }, - undefined, - true - ); + return await HttpClient.post(`${this.baseUrl}/filter/`, undefined, { filters }); } catch (e) { throw e; } @@ -36,7 +32,18 @@ export default class QaMetricsApi { public static async getQAMetricsData() { try { - return await HttpClient.get(`${this.baseUrl}/predictions_info/`, undefined, undefined, true); + const res = await HttpClient.get(`${this.baseUrl}/predictions_info/`); + + res.ttr_chart = createChartDataFromObject(res.ttr_chart); + res.priority_chart = createChartDataFromObject(res.priority_chart); + res.resolution_chart = Object.entries(res.resolution_chart).map(([name, data]) => { + return { + name, + data: createChartDataFromObject(data as ObjectWithUnknownFields), + }; + }); + + return res; } catch (e) { throw e; } @@ -44,13 +51,10 @@ export default class QaMetricsApi { public static async getQAMetricsPredictionsTable(limit: number, offset: number) { try { - return await HttpClient.post( - `${this.baseUrl}/predictions_table/`, - undefined, - { limit, offset }, - undefined, - true - ); + return await HttpClient.post(`${this.baseUrl}/predictions_table/`, undefined, { + limit, + offset, + }); } catch (e) { throw e; } diff --git a/frontend/src/app/common/api/settings.api.ts b/frontend/src/app/common/api/settings.api.ts index 2733e7e..fc04308 100644 --- a/frontend/src/app/common/api/settings.api.ts +++ b/frontend/src/app/common/api/settings.api.ts @@ -1,22 +1,60 @@ /* eslint-disable import/prefer-default-export */ import HttpClient from "app/common/api/http-client"; -import { SettingsSections, SettingsDataUnion } from "app/common/store/settings/types"; +import { FilterData, PredictionTableData } from "app/common/store/settings/types"; import { copyData } from "app/common/functions/helper"; +import { SettingsTrainingSendType, TrainingSubSection } from "app/modules/settings/parts/training/store/types"; export class SettingsApi { static baseUrl = "settings"; - public static async getSettingsData(section: SettingsSections, subSection?: string) { - return HttpClient.get(`${this.baseUrl}/${section}/${subSection ? `${subSection}/` : ""}`); + public static async getSettingsTrainingData(subSection?: string) { + return HttpClient.get(`${this.baseUrl}/training/${subSection}/`); } - public static async sendSettingsData( - section: SettingsSections, - data: SettingsDataUnion, - subSection?: string - ) { + public static async getSettingsATFiltersData() { + return HttpClient.get(`${this.baseUrl}/filters/`); + } + + public static async getSettingsQAMetricsFiltersData() { + return HttpClient.get(`${this.baseUrl}/qa_metrics/`); + } + + public static async getSettingsPredictionsData() { + return HttpClient.get(`${this.baseUrl}/predictions_table/`); + } + + public static async sendSettingsATFiltersData( + data: FilterData[]) { + return HttpClient.post( + `${this.baseUrl}/filters/`, + {}, + copyData(data) + ); + } + + public static async sendSettingsQAMetricsFiltersData( + data: FilterData[]) { + return HttpClient.post( + `${this.baseUrl}/qa_metrics/`, + {}, + copyData(data) + ); + } + + public static async sendSettingsPredictionsData( + data: PredictionTableData[]) { + return HttpClient.post( + `${this.baseUrl}/predictions_table/`, + {}, + copyData(data) + ); + } + + public static async sendSettingsTrainingDataData( + data: SettingsTrainingSendType, + subSection?: TrainingSubSection) { return HttpClient.post( - `${this.baseUrl}/${section}/${subSection ? `${subSection}/` : ""}`, + `${this.baseUrl}/training/${subSection ? `${subSection}/` : ""}`, {}, copyData(data) ); diff --git a/frontend/src/app/common/api/sockets.ts b/frontend/src/app/common/api/sockets.ts index 702683f..fc83b39 100644 --- a/frontend/src/app/common/api/sockets.ts +++ b/frontend/src/app/common/api/sockets.ts @@ -1,11 +1,24 @@ import { getDataFromLocalStorage } from "app/common/functions/local-storage"; import { User } from "app/common/types/user.types"; +export enum SocketEventType { + updateCountIssues = 'UPDATE_COUNT_ISSUES' +} + +export interface SocketEvent { + type: SocketEventType; + data?: unknown; +} + +type callback = (...params: unknown[]) => unknown; + export default class SocketClient { + private token = ""; - host: string; - ws: WebSocket | undefined = undefined; + private readonly host: string; + private ws: WebSocket | undefined = undefined; + private subscribers: Map = new Map() constructor() { if (process.env.NODE_ENV === "development") { @@ -27,17 +40,45 @@ export default class SocketClient { return this.ws; } - // TODO: eslint-disable-next-line - // eslint-disable-next-line - startMonitor(type: string, cb: (data: any) => void): void { + private startWatching() { const sockets = this.ws || this.connectToSocket(); - // TODO: eslint-disable-next-line - // eslint-disable-next-line - sockets.addEventListener(type, (event: any) => { - // TODO: @typescript-eslint/no-unsafe-member-access - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - cb(event.data); - }); + sockets.addEventListener("message", (event: MessageEvent) => { + let eventData: SocketEvent; + + try { + eventData = JSON.parse(event.data); + } catch (e) { + eventData = { type: event.data }; + } + + const cb = this.subscribers.get(eventData.type); + + if (cb) { + cb((event.data as SocketEvent).data) + } + }) + } + + subscribeToEvent(type: SocketEventType, cb: callback) { + if (this.subscribers.size === 0) { + this.startWatching(); + } + + // TODO: add ability set some subscribes to one event type + if (this.subscribers.has(type)) { + throw new Error('This type event already has subscriber'); + } + + this.subscribers.set(type, cb); + } + + unsubscribe(type: SocketEventType) { + this.subscribers.delete(type); + + if (this.subscribers.size === 0) { + this.ws?.close(); + this.ws = undefined; + } } } diff --git a/frontend/src/app/common/components/backlight-textarea/backlight-textarea.scss b/frontend/src/app/common/components/backlight-textarea/backlight-textarea.scss index c4d2493..d1ec62a 100644 --- a/frontend/src/app/common/components/backlight-textarea/backlight-textarea.scss +++ b/frontend/src/app/common/components/backlight-textarea/backlight-textarea.scss @@ -16,6 +16,8 @@ font-size: 16px; line-height: 20px; color: $darkGray; + + word-break: break-all; } &__placeholder, diff --git a/frontend/src/app/common/components/charts/bar-chart/bar-chart.tsx b/frontend/src/app/common/components/charts/bar-chart/bar-chart.tsx index 0cc01ad..b7ecfc5 100644 --- a/frontend/src/app/common/components/charts/bar-chart/bar-chart.tsx +++ b/frontend/src/app/common/components/charts/bar-chart/bar-chart.tsx @@ -1,3 +1,4 @@ +import { ChartData } from "app/common/components/charts/types"; import React, { CSSProperties, ReactElement, RefObject } from "react"; import cn from "classnames"; @@ -5,7 +6,7 @@ import "app/common/components/charts/bar-chart/bar-chart.scss"; interface IProps { percentage: boolean; - data: BarChartData; + data: ChartData; verticalDirection: boolean; multiColors: boolean; } @@ -14,10 +15,6 @@ interface IState { maxChartSize: number; } -type BarChartData = { - [key: string]: number; -}; - export type BarChartColumn = { value: number; label: string; @@ -58,11 +55,7 @@ export class CustomBarChart extends React.Component { render(): ReactElement { const { props, state } = this; - - const data: BarChartColumn[] = Object.entries(props.data).map(([columnName, value]) => ({ - label: columnName, - value, - })); + const { data } = props; let maxValue = 0; let minValue = data[0].value; @@ -88,8 +81,8 @@ export class CustomBarChart extends React.Component { } return ( -
-
{item.label}
+
+
{item.name}
{!!item.value && ( diff --git a/frontend/src/app/common/components/charts/donut-chart/donut-chart.scss b/frontend/src/app/common/components/charts/donut-chart/donut-chart.scss index 14098a8..bda0198 100644 --- a/frontend/src/app/common/components/charts/donut-chart/donut-chart.scss +++ b/frontend/src/app/common/components/charts/donut-chart/donut-chart.scss @@ -8,8 +8,8 @@ justify-content: flex-start; &-legend { - max-width: 150px; - align-self: flex-end; + margin-right: 25px; + align-self: center; &__wrapper { display: flex; @@ -38,15 +38,13 @@ line-height: 19px; font-feature-settings: "cpsp" on, "liga" off; color: $gray; - @media screen and (max-width: 1280px) { - font-size: 13px; - } } } &-wrapper { - width: 50%; position: relative; + width: 140px; + flex-shrink: 0; &__percentage-block { position: absolute; @@ -55,6 +53,7 @@ transform: translate(-50%, -50%); opacity: 0; transition: opacity 0.2s ease-out; + font-size: 25px; &_visible { opacity: 1; @@ -62,3 +61,24 @@ } } } + +@media screen and (max-width: 1600px) { + .donut-chart-wrapper { + width: 120px; + } +} + +@media screen and (max-width: 1280px) { + .donut-chart-legend { + margin-right: 15px; + + &__wrapper { + &:not(:last-child) { + margin-bottom: 15px; + } + } + &__title { + font-size: 13px; + } + } +} diff --git a/frontend/src/app/common/components/charts/donut-chart/donut-chart.tsx b/frontend/src/app/common/components/charts/donut-chart/donut-chart.tsx index 90535dc..fdaf234 100644 --- a/frontend/src/app/common/components/charts/donut-chart/donut-chart.tsx +++ b/frontend/src/app/common/components/charts/donut-chart/donut-chart.tsx @@ -1,3 +1,4 @@ +import { ChartData } from "app/common/components/charts/types"; import React from "react"; import { ResponsivePie } from "@nivo/pie"; import cn from "classnames"; @@ -9,10 +10,6 @@ export const DonutChartColorSchemes = { orangeViolet: ["#FFA666", "#BCAAF2"], }; -export type DonutChartData = { - [key: string]: number; -}; - export type DonutChartSector = { value: number; label: string; @@ -23,7 +20,7 @@ export type DonutChartSector = { interface IProps { className?: string; colorSchema?: string[]; - data: DonutChartData; + data: ChartData; } interface iState { @@ -50,10 +47,10 @@ class DonutChart extends React.Component { constructor(props: IProps) { super(props); - this.data = Object.entries(this.props.data).map(([sectorName, value], index) => ({ - label: sectorName, - id: sectorName, - value: Math.round(value), + this.data = this.props.data.map((sector, index) => ({ + label: sector.name, + id: sector.name, + value: Math.round(sector.value), color: this.props.colorSchema![index], })); } diff --git a/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.scss b/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.scss index 05219f5..ee9a261 100644 --- a/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.scss +++ b/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.scss @@ -18,12 +18,14 @@ .top-left { display: flex; + align-items: flex-start; flex-wrap: wrap-reverse; flex-direction: row-reverse; } .top-right { display: flex; + align-items: flex-start; flex-wrap: wrap-reverse; align-content: flex-start; } @@ -44,9 +46,10 @@ margin: 2.5px; display: inline-flex; height: 2em; - padding: 0 1em; - border-radius: 0.3em; + border-radius: 5px; align-items: center; + justify-content: center; + cursor: pointer; &_color { &_violet { diff --git a/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.tsx b/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.tsx index 009743a..abd9dd3 100644 --- a/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.tsx +++ b/frontend/src/app/common/components/charts/tag-cloud/tag-cloud.tsx @@ -1,6 +1,6 @@ import { TagCloudGenerator } from "app/common/components/charts/tag-cloud/tag-cloud-generator"; +import TermBlock from "app/common/components/charts/tag-cloud/term-block"; import { Tag } from "app/common/components/charts/tag-cloud/types"; -import Tooltip from "app/common/components/tooltip/tooltip"; import { Terms } from "app/modules/significant-terms/store/types"; import cn from "classnames"; import React from "react"; @@ -13,7 +13,20 @@ interface IProps { percentage?: boolean; } -export class TagCloud extends React.Component { +interface IState { + screenCoefficient: number; + tagList: Tag[][]; +} + +export class TagCloud extends React.Component { + constructor(props: IProps) { + super(props); + this.state = { + screenCoefficient: this.getScreenSizeCoefficient(), + tagList: new TagCloudGenerator().prepare(props.tags), + }; + } + getShortVersion(termName: string): string { if (termName.length > 9) { return `${termName.slice(0, 8)}...`; @@ -22,38 +35,44 @@ export class TagCloud extends React.Component { } getScreenSizeCoefficient = () => { - if (window.screen.width < 1600) return 1; - else if (window.screen.width >= 1600 && window.screen.width < 1920) return 1600 / 1280; - else return 1920 / 1280; + const coeff = 0.9; + if (window.innerWidth < 1920) return (coeff * window.innerWidth) / 1280; + return (coeff * 1920) / 1280; }; + recalculateScreenCoefficient = () => { + this.setState({ + screenCoefficient: this.getScreenSizeCoefficient(), + }); + }; - renderBlock = (termsList: Tag[], position: string) => { - const standardSize = 12; - const screenCoefficient = this.getScreenSizeCoefficient(); + componentDidMount = () => { + window.addEventListener("resize", this.recalculateScreenCoefficient); + }; - return ( -
- {termsList.map(({ name, size, absoluteValue, color }, index) => ( -
- - {this.getShortVersion(name)} - -
- ))} -
- ); + componentWillUnmount = () => { + window.removeEventListener("resize", this.recalculateScreenCoefficient); }; + renderBlock = (termsList: Tag[], position: string) => ( +
+ {termsList.map(({ name, size, absoluteValue, color }, index) => ( + + ))} +
+ ); + render() { - const tagList = new TagCloudGenerator().prepare(this.props.tags); + const { tagList } = this.state; return (
diff --git a/frontend/src/app/common/components/charts/tag-cloud/term-block.tsx b/frontend/src/app/common/components/charts/tag-cloud/term-block.tsx new file mode 100644 index 0000000..4ef4def --- /dev/null +++ b/frontend/src/app/common/components/charts/tag-cloud/term-block.tsx @@ -0,0 +1,50 @@ +import cn from "classnames"; +import React, { useEffect, useRef, useState } from "react"; + +interface TermBlockProps { + name: string; + shortName: string; + value: string; + color: string; + size: number; + screenCoefficient: number; + zIndex: number; +} + +export default function TermBlock(props: TermBlockProps) { + const [isHovered, setHoverStatus] = useState(false); + const [termSize, setTermSize] = useState({ width: 0, height: 0 }); + const termRef = useRef(null); + useEffect(() => { + if (termRef.current) { + const termRectSize = termRef.current.getBoundingClientRect(); + setTermSize({ width: termRectSize.width, height: termRectSize.height }); + } + }, [props.size, props.screenCoefficient]); + + const standardSize = 12; + const fullTermTitleCoefficient = isHovered + ? props.shortName.length / (props.name.length + 0.5 * props.value.length) + : 1; + + return ( +
setHoverStatus(true)} + onMouseLeave={() => setHoverStatus(false)} + className={cn("tag-cloud__term", `tag-cloud__term_color_${props.color}`)} + style={{ + fontSize: Math.floor( + (props.size || standardSize) * props.screenCoefficient * fullTermTitleCoefficient + ), + padding: isHovered ? 0 : "0 1em", + zIndex: 10 - props.zIndex, + width: isHovered ? termSize.width : "", + height: isHovered ? termSize.height : "", + }} + > + {isHovered ? props.name : props.shortName} + {isHovered &&  {props.value}} +
+ ); +} diff --git a/frontend/src/app/common/components/charts/types.ts b/frontend/src/app/common/components/charts/types.ts new file mode 100644 index 0000000..2c92da6 --- /dev/null +++ b/frontend/src/app/common/components/charts/types.ts @@ -0,0 +1,13 @@ +export type ChartsList = Chart[]; + +export interface Chart { + name: string, + data: ChartData +} + +export type ChartData = ChartItemData[]; + +export interface ChartItemData { + name: string, + value: number +} diff --git a/frontend/src/app/common/components/native-components/dropdown-element/dropdown-element.tsx b/frontend/src/app/common/components/native-components/dropdown-element/dropdown-element.tsx index 2cabf9e..2d6968b 100644 --- a/frontend/src/app/common/components/native-components/dropdown-element/dropdown-element.tsx +++ b/frontend/src/app/common/components/native-components/dropdown-element/dropdown-element.tsx @@ -7,20 +7,28 @@ import cn from "classnames"; import "app/common/components/native-components/dropdown-element/dropdown-element.scss"; import { caseInsensitiveStringCompare } from "app/common/functions/helper"; +interface DropdownVariant { + value: string; + label: string; +} + +type DropdownVariantsType = Array; + interface DropdownProps { type: FilterElementType; - value: string; - dropDownValues: string[]; + value: string | DropdownVariant; + dropDownValues: DropdownVariantsType; writable: boolean; excludeValues: string[]; onChange: (s: string) => void; onClear?: () => void; style?: React.CSSProperties; placeholder?: string; + className?: string; } interface DropdownState { - inputValue: string; + inputValue: string | DropdownVariant; isDropDownWrapperOpened: boolean; } @@ -52,11 +60,11 @@ class DropdownElement extends Component { this.setState({ isDropDownWrapperOpened: false }); }; - selectDropdownOption = (inputValue: string) => (): void => { + selectDropdownOption = (newValue: string | DropdownVariant) => (): void => { const { onChange, writable } = this.props; - this.setState({ inputValue }, this.blurDropDownElement); - onChange(inputValue); + this.setState({ inputValue: newValue }, this.blurDropDownElement); + onChange(typeof newValue === 'string' ? newValue : newValue.value); if (!writable && this.dropdownElementRef.current) this.dropdownElementRef.current.blur(); }; @@ -71,9 +79,11 @@ class DropdownElement extends Component { }; shouldComponentUpdate = (nextProps: DropdownProps): boolean => { - const { value } = this.props; - if (!nextProps.value.length && value) this.setState({ inputValue: "" }); - if (nextProps.value !== value) this.setState({ inputValue: nextProps.value }); + let value = typeof this.props.value === "string" ? this.props.value : this.props.value.value; + let newValue = typeof nextProps.value === "string" ? nextProps.value : nextProps.value.value; + + if (!newValue.length && value) this.setState({ inputValue: "" }); + if (newValue !== value) this.setState({ inputValue: nextProps.value }); return true; }; @@ -105,21 +115,37 @@ class DropdownElement extends Component { const isInputEditable: boolean = allowedOpening && writable; - let dropDownOptions: string[] = [...dropDownValues]; + let dropDownOptions: DropdownVariantsType = [...dropDownValues]; if (isInputEditable) { - dropDownOptions = dropDownOptions.filter((str: string) => { - if (inputValue.length) - return this.isStrIncludesSubstr(str, inputValue) && !excludeValues?.includes(str); + dropDownOptions = dropDownOptions.filter((variant: string | DropdownVariant) => { + let inputValueTemp = typeof inputValue === "string" ? inputValue : inputValue.value; + if (inputValueTemp.length) { + if (typeof variant === "string") { + return ( + this.isStrIncludesSubstr(variant, inputValueTemp) && !excludeValues?.includes(variant) + ); + } else { + return ( + this.isStrIncludesSubstr(variant.value, inputValueTemp) && + !excludeValues?.includes(variant.value) + ); + } + } return true; }); } - dropDownOptions.sort((a, b) => caseInsensitiveStringCompare(a, b)); + dropDownOptions.sort((a, b) => { + return caseInsensitiveStringCompare( + typeof a === "string" ? a : a.value, + typeof b === "string" ? b : b.value + ); + }); return (
{ style={style} > { "dropdown-element-wrapper_hidden": !isDropDownWrapperOpened, })} > - {dropDownOptions.map((item) => ( + {dropDownOptions.map((item) => { // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
({})} - onClick={this.selectDropdownOption(item)} - key={item} - > - {item} -
- ))} + + let label = typeof item === 'string' ? item : item.label; + let value = typeof item === 'string' ? item : item.value; + let inputValueTemp = typeof inputValue === "string" ? inputValue : inputValue.value; + + return ( +
({})} + onClick={this.selectDropdownOption(item)} + key={value} + > + {label} +
+ ); + })}
)} diff --git a/frontend/src/app/common/components/native-components/select-window/select-window.scss b/frontend/src/app/common/components/native-components/select-window/select-window.scss index 4b3ea29..2011263 100644 --- a/frontend/src/app/common/components/native-components/select-window/select-window.scss +++ b/frontend/src/app/common/components/native-components/select-window/select-window.scss @@ -91,6 +91,10 @@ border-radius: 2px; } + &__title { + word-break: break-all; + } + &__check-mark { width: 100%; height: 100%; diff --git a/frontend/src/app/common/components/native-components/select-window/select-window.tsx b/frontend/src/app/common/components/native-components/select-window/select-window.tsx index feb1c16..5ed144a 100644 --- a/frontend/src/app/common/components/native-components/select-window/select-window.tsx +++ b/frontend/src/app/common/components/native-components/select-window/select-window.tsx @@ -20,9 +20,12 @@ export default function SelectWindow(props: SelectWindowProps): ReactElement { const { searchable, placeholder, children, selectWindowAllValues } = props; - const filteredValues: string[] = [...selectWindowAllValues].filter((str) => - isStrIncludesSubstr(str.toString(), quickSearchValue) - ); + let filteredValues: string[] = [...selectWindowAllValues]; + if (searchable) { + filteredValues = filteredValues.filter((str) => + isStrIncludesSubstr(str.toString(), quickSearchValue) + ); + } return (
@@ -75,8 +78,7 @@ export default function SelectWindow(props: SelectWindowProps): ReactElement { /> )} - - {item} + {item} ); }) diff --git a/frontend/src/app/common/components/popup-component/popup-component.tsx b/frontend/src/app/common/components/popup-component/popup-component.tsx index 98a13c7..d06a07c 100644 --- a/frontend/src/app/common/components/popup-component/popup-component.tsx +++ b/frontend/src/app/common/components/popup-component/popup-component.tsx @@ -7,6 +7,10 @@ import "./popup-component.scss"; export enum ChildPosition { top = "top", + bottom = "bottom", + left = "left", + right = "right", + bottom_right = "bottom_right", } @@ -69,6 +73,11 @@ export default class PopupComponent extends React.Component switch (childPosition) { case ChildPosition.top: return childCoeffs; + case ChildPosition.bottom: + return { + ...childCoeffs, + top: parentCoords.height, + }; case ChildPosition.bottom_right: return { top: parentCoords.height, diff --git a/frontend/src/app/common/components/toast/toast.tsx b/frontend/src/app/common/components/toast/toast.tsx index 4c5ad12..ab0555a 100644 --- a/frontend/src/app/common/components/toast/toast.tsx +++ b/frontend/src/app/common/components/toast/toast.tsx @@ -62,7 +62,9 @@ class Toast extends React.Component { this.setState((state, props) => ({ expandable: props.toast.actionToast || - (this.messageRef.current ? this.messageRef.current.scrollWidth > 350 : false), + (this.messageRef.current + ? this.messageRef.current.scrollWidth > this.messageRef.current.clientWidth + : false), })); }; diff --git a/frontend/src/app/common/components/tooltip/tooltip.scss b/frontend/src/app/common/components/tooltip/tooltip.scss index 8b4e364..30f45b1 100644 --- a/frontend/src/app/common/components/tooltip/tooltip.scss +++ b/frontend/src/app/common/components/tooltip/tooltip.scss @@ -1,6 +1,6 @@ @import "../../../styles/colors"; -$horizontaTriangleSize: 7.5px; +$horizontalTriangleSize: 7.5px; $verticalTriangleSize: 10px; .tooltip { @@ -23,21 +23,47 @@ $verticalTriangleSize: 10px; visibility: visible; opacity: 1; } + &_hided { visibility: hidden; opacity: 0; } + &_bottom-reverted, + &_top-reverted, + &_left-reverted, + &_right-reverted { + .tooltip-wrapper__content { + transform: scaleX(-1); + } + } + &_bottom { bottom: 0; flex-direction: column-reverse; transform: translateY(calc(100% + #{$verticalTriangleSize}/ 2)); + + &-reverted { + transform: scaleX(-1) + translate( + calc(100% - #{$horizontalTriangleSize} * 5), + calc(100% + #{$verticalTriangleSize}/ 2) + ); + } } &_top { top: 0; flex-direction: column; transform: translateY(calc(-100% - #{$verticalTriangleSize}/ 2)); + + &-reverted { + transform: scaleX(-1) + translate( + calc(100% - #{$horizontalTriangleSize} * 5), + calc(-100% - #{$verticalTriangleSize}/ 2) + ); + } } &_left { @@ -57,10 +83,12 @@ $verticalTriangleSize: 10px; } &__content { + max-width: 400px; + width: max-content; + white-space: pre-wrap; background: $smoothOrange; border-radius: 8px; padding: 12px 25px; - white-space: nowrap; font-size: 15px; line-height: 19px; @@ -86,13 +114,13 @@ $verticalTriangleSize: 10px; &_right { margin-right: -1px; - border-width: $horizontaTriangleSize $horizontaTriangleSize $horizontaTriangleSize 0; + border-width: $horizontalTriangleSize $horizontalTriangleSize $horizontalTriangleSize 0; border-color: transparent $smoothOrange transparent transparent; } &_left { margin-left: -1px; - border-width: $horizontaTriangleSize 0 $horizontaTriangleSize $horizontaTriangleSize; + border-width: $horizontalTriangleSize 0 $horizontalTriangleSize $horizontalTriangleSize; border-color: transparent transparent transparent $smoothOrange; } } diff --git a/frontend/src/app/common/components/tooltip/tooltip.tsx b/frontend/src/app/common/components/tooltip/tooltip.tsx index d6a1432..7403f64 100644 --- a/frontend/src/app/common/components/tooltip/tooltip.tsx +++ b/frontend/src/app/common/components/tooltip/tooltip.tsx @@ -1,7 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable react/static-property-placement */ import React, { CSSProperties } from "react"; import { Timer } from "app/common/functions/timer"; import PopupComponent, { @@ -30,11 +26,11 @@ interface TooltipProps { children: React.ReactNode; isDisplayed: boolean; style?: CSSProperties; - tooltipOuterRef?: React.RefObject; } interface TooltipState { wrapperDisplayStyle: TooltipWrapperShowing; + isReverted: boolean; } // Change tooltip adding approach @@ -46,12 +42,15 @@ class Tooltip extends React.Component { isDisplayed: true, }; + tooltipRef: React.RefObject = React.createRef(); + tooltipRect: DOMRect | undefined = undefined; timer: any = {}; constructor(props: TooltipProps) { super(props); this.state = { wrapperDisplayStyle: TooltipWrapperShowing.hide, + isReverted: false, }; } @@ -73,15 +72,42 @@ class Tooltip extends React.Component { }); }; + componentDidUpdate = () => { + this.setTooltipRect(); + this.checkTooltipRevertStatus(); + }; + + setTooltipRect = () => { + if (!this.tooltipRef?.current) return; + + this.tooltipRect = this.tooltipRef.current.getBoundingClientRect(); + }; + + checkTooltipRevertStatus = () => { + if (!this.tooltipRect) return; + + if ( + !this.state.isReverted && + this.tooltipRect.left + this.tooltipRect.width > window.innerWidth + ) { + this.setState({ isReverted: true }); + } else if ( + this.state.isReverted && + this.tooltipRect.right + this.tooltipRect.width <= window.innerWidth + ) { + this.setState({ isReverted: false }); + } + }; + render() { - const { wrapperDisplayStyle } = this.state; - const { isDisplayed, children, position, style, tooltipOuterRef, message } = this.props; + const { wrapperDisplayStyle, isReverted } = this.state; + const { isDisplayed, children, position, style, message } = this.props; return (
{ "tooltip-wrapper", { "tooltip-wrapper_displayed": isDisplayed }, `tooltip-wrapper_${position}`, + `tooltip-wrapper_${position}${isReverted ? "-reverted" : ""}`, `tooltip-wrapper_${wrapperDisplayStyle}` )} style={style} onMouseEnter={this.timer.pause} onMouseLeave={this.timer.resume} - ref={tooltipOuterRef} + ref={this.tooltipRef} > -
{message}
+

{message}

diff --git a/frontend/src/app/common/functions/helper.ts b/frontend/src/app/common/functions/helper.ts index 445ca80..629ba75 100644 --- a/frontend/src/app/common/functions/helper.ts +++ b/frontend/src/app/common/functions/helper.ts @@ -1,5 +1,5 @@ -import { DAProbabilitiesData } from "app/pages/description-assessment/description-assessment.page"; -import { QAMetricsData } from "../store/qa-metrics/types"; +import { ChartData } from "app/common/components/charts/types"; +import { ObjectWithUnknownFields } from "app/common/types/http.types"; export function isOneOf(requiredElem: T, allElem: T[]): boolean { return allElem.findIndex((elem) => elem === requiredElem) > -1; @@ -15,21 +15,25 @@ export function copyData(data: any) { return data; } +export function deepCopyData(data: T) { + return JSON.parse(JSON.stringify(data)) as T; +} + export const isStrIncludesSubstr = (str: string, substr: string) => { return str.toUpperCase().includes(substr.toUpperCase()); }; export function caseInsensitiveStringCompare(a: string, b: string) { - return a.toUpperCase().trim().localeCompare(b.toUpperCase().trim()); + return a.trim().localeCompare(b.trim(), undefined, { numeric: true, sensitivity: "base" }); } // TTR processing functions -function sortTTRKeys(oldKeys: Array<[string, unknown]>) { - oldKeys.sort((a, b) => { +function sortByTTRKeys(ttrData: ChartData) { + return ttrData.sort((a, b) => { const splitRegex = /(-| |>)+/; - const aArr = a[0].split(splitRegex); - const bArr = b[0].split(splitRegex); + const aArr = a.name.split(splitRegex); + const bArr = b.name.split(splitRegex); if (!aArr[0].length || (Number(aArr[0]) > Number(bArr[0]) && Number(aArr[2]) > Number(bArr[2]))) return 1; @@ -51,24 +55,31 @@ function convertTTRNumberToDay(key: string) { return `${oldAxisArray.join("")} days`; } -export function fixTTRBarChartAxisDisplayStyle(oldTTRData: DAProbabilitiesData) { - let newTTRData: DAProbabilitiesData = {}; - const ttrKeyArr: Array<[string, unknown]> = []; +export function fixTTRBarChartAxisDisplayStyle(ttrData: ChartData): ChartData { + let res: ChartData; - Object.entries(oldTTRData).forEach(([key, val]) => { - const oldAxisArray = convertTTRNumberToDay(key); - ttrKeyArr.push([oldAxisArray, val]); + res = ttrData.map((item) => { + item.name = convertTTRNumberToDay(item.name); + return item }); - sortTTRKeys(ttrKeyArr); - newTTRData = Object.fromEntries(ttrKeyArr); + res = sortByTTRKeys(res); - return newTTRData; + return res; } -export function fixTTRPredictionTableDisplayStyle(data: QAMetricsData[]) { +export function fixTTRPredictionTableDisplayStyle(data: ObjectWithUnknownFields[]) { return data.map((item) => { item["Time to Resolve"] = convertTTRNumberToDay(item["Time to Resolve"] as string); return item; }); } + +export function createChartDataFromObject(object: ObjectWithUnknownFields): ChartData { + return Object.entries(object).map(([name, value]) => { + return { + name, + value + } + }) +} diff --git a/frontend/src/app/common/store/analysis-and-training/actions.ts b/frontend/src/app/common/store/analysis-and-training/actions.ts new file mode 100644 index 0000000..6281fae --- /dev/null +++ b/frontend/src/app/common/store/analysis-and-training/actions.ts @@ -0,0 +1,73 @@ +import { + AnalysisAndTrainingStatuses, + AnalysisAndTrainingWarnings +} from "app/common/store/analysis-and-training/types"; +import { + AnalysisAndTrainingStatistic, + DefectSubmissionData, + SignificantTermsData, +} from "app/common/types/analysis-and-training.types"; +import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { MainStatisticData } from "app/modules/main-statistic/main-statistic"; +import { Terms } from "app/modules/significant-terms/store/types"; + +export const setTotalStatistic = (statistic: MainStatisticData) => + ({ + type: "SET_A&T_TOTAL_STATISTIC", + statistic, + } as const); + +export const setSignificantTerms = (significantTerms: SignificantTermsData) => + ({ + type: "SET_A&T_SIGNIFICANT_TERMS", + significantTerms, + } as const); + +export const updateSignificantTermsChosenMetric = (metric: string) => + ({ + type: "UPDATE_A&T_SIGNIFICANT_TERMS_CHOSEN_METRIC", + metric, + } as const); + +export const updateSignificantTermsList = (terms: Terms) => + ({ + type: "UPDATE_A&T_SIGNIFICANT_TERMS_LIST", + terms, + } as const); + +export const setDefectSubmission = (defectSubmission: DefectSubmissionData) => + ({ + type: "SET_A&T_DEFECT_SUBMISSION", + defectSubmission, + } as const); + +export const setFrequentlyTerms = (frequentlyTerms: string[]) => + ({ + type: "SET_A&T_FREQUENTLY_TERMS", + frequentlyTerms, + } as const); + +export const setStatistic = (statistic: AnalysisAndTrainingStatistic) => + ({ + type: "SET_A&T_STATISTIC", + statistic, + } as const); + +export const setCardStatuses = (statuses: Partial) => + ({ + type: "SET_A&T_STATUSES", + statuses, + } as const); + +export const setCardWarnings = (warnings: Partial) => + ({ + type: "SET_A&T_WARNINGS", + warnings, + } as const); + + +export const setFilters = (filters: FilterFieldBase[]) => + ({ + type: "SET_A&T_FILTERS", + filters, + } as const); diff --git a/frontend/src/app/common/store/analysis-and-training/reducers.ts b/frontend/src/app/common/store/analysis-and-training/reducers.ts new file mode 100644 index 0000000..a891949 --- /dev/null +++ b/frontend/src/app/common/store/analysis-and-training/reducers.ts @@ -0,0 +1,114 @@ +import { AnalysisAndTrainingStore } from "app/common/store/analysis-and-training/types"; +import { InferValueTypes } from "app/common/store/utils"; +import { HttpStatus } from "app/common/types/http.types"; +import * as actions from "./actions"; + +const initialState: AnalysisAndTrainingStore = { + filters: [], + totalStatistic: undefined, + frequentlyTerms: [], + statistic: {}, + significantTerms: { + metrics: [], + chosen_metric: null, + terms: {}, + }, + defectSubmission: { + created_line: {}, + resolved_line: {}, + created_total_count: 0, + resolved_total_count: 0, + period: "", + }, + statuses: { + filter: HttpStatus.PREVIEW, + frequentlyTerms: HttpStatus.PREVIEW, + defectSubmission: HttpStatus.PREVIEW, + statistic: HttpStatus.PREVIEW, + significantTerms: HttpStatus.PREVIEW, + }, + warnings: { + frequentlyTerms: '', + significantTerms: '', + }, +}; + +type actionsUserTypes = ReturnType>; + +export const analysisAndTrainingReducers = (state: AnalysisAndTrainingStore = initialState, action: actionsUserTypes): AnalysisAndTrainingStore => { + switch (action.type) { + + case "SET_A&T_STATUSES": return { + ...state, + statuses: { + ...state.statuses, + ...action.statuses + } + } + + case "SET_A&T_WARNINGS": return { + ...state, + warnings: { + ...state.warnings, + ...action.warnings + } + } + + case "SET_A&T_SIGNIFICANT_TERMS": return { + ...state, + significantTerms: { + ...action.significantTerms + } + } + + case "UPDATE_A&T_SIGNIFICANT_TERMS_CHOSEN_METRIC": return { + ...state, + significantTerms: { + ...state.significantTerms, + chosen_metric: action.metric + } + } + + case "UPDATE_A&T_SIGNIFICANT_TERMS_LIST": return { + ...state, + significantTerms: { + ...state.significantTerms, + terms: { ...action.terms } + } + } + + case "SET_A&T_DEFECT_SUBMISSION": return { + ...state, + defectSubmission: { + ...action.defectSubmission + } + } + + case "SET_A&T_FREQUENTLY_TERMS": return { + ...state, + frequentlyTerms: [ ...action.frequentlyTerms ] + } + + case "SET_A&T_STATISTIC": return { + ...state, + statistic: { + ...action.statistic + } + } + + case "SET_A&T_FILTERS": return { + ...state, + filters: [ ...action.filters ] + } + + case "SET_A&T_TOTAL_STATISTIC": return { + ...state, + totalStatistic: { + ...action.statistic + } + } + + default: + return { ...state }; + } +}; diff --git a/frontend/src/app/common/store/analysis-and-training/thunks.ts b/frontend/src/app/common/store/analysis-and-training/thunks.ts new file mode 100644 index 0000000..6aa94a2 --- /dev/null +++ b/frontend/src/app/common/store/analysis-and-training/thunks.ts @@ -0,0 +1,368 @@ +import { AnalysisAndTrainingApi } from "app/common/api/analysis-and-training.api"; +import { + setCardStatuses, setCardWarnings, + setDefectSubmission, + setFilters, + setFrequentlyTerms, + setSignificantTerms, + setStatistic, + setTotalStatistic, + updateSignificantTermsChosenMetric, + updateSignificantTermsList +} from "app/common/store/analysis-and-training/actions"; +import { checkIssuesExist } from "app/common/store/common/utils"; +import { + AnalysisAndTrainingStatistic, + DefectSubmissionData, + SignificantTermsData, +} from "app/common/types/analysis-and-training.types"; +import { HttpStatus } from "app/common/types/http.types"; +import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { FiltersPopUp } from "app/modules/filters/filters"; +import { MainStatisticData } from "app/modules/main-statistic/main-statistic"; +import { Terms } from "app/modules/significant-terms/store/types"; +import { addToast } from "app/modules/toasts-overlay/store/actions"; +import { ToastStyle } from "app/modules/toasts-overlay/store/types"; + +export const uploadDashboardData = () => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + filter: HttpStatus.LOADING, + frequentlyTerms: HttpStatus.LOADING, + defectSubmission: HttpStatus.LOADING, + statistic: HttpStatus.LOADING, + significantTerms: HttpStatus.LOADING, + }) + ); + + let totalStatistic: MainStatisticData; + + if (await checkIssuesExist()) { + dispatch(uploadFilters()); + totalStatistic = await dispatch(uploadTotalStatistic()); + } else { + dispatch( + setCardStatuses({ + filter: HttpStatus.PREVIEW, + frequentlyTerms: HttpStatus.PREVIEW, + defectSubmission: HttpStatus.PREVIEW, + statistic: HttpStatus.PREVIEW, + significantTerms: HttpStatus.PREVIEW, + }) + ) + return; + } + + if (totalStatistic.filtered) { + dispatch(uploadFrequentlyTerms()); + dispatch(uploadStatistic()); + dispatch(uploadDefectSubmission()); + dispatch(uploadSignificantTermsData()); + } else { + dispatch( + addToast( + "With cached filters we didn't find data. Try to change filter.", + ToastStyle.Warning + ) + ); + + dispatch( + setCardStatuses({ + frequentlyTerms: HttpStatus.PREVIEW, + defectSubmission: HttpStatus.PREVIEW, + statistic: HttpStatus.PREVIEW, + significantTerms: HttpStatus.PREVIEW, + }) + ) + } + }; +}; + +export const uploadTotalStatistic = () => { + return async (dispatch: any) => { + + let records_count: MainStatisticData; + + try { + records_count = await AnalysisAndTrainingApi.getTotalStatistic(); + } catch (e) { + // don't have loading status + return; + } + + dispatch(setTotalStatistic(records_count)); + + return records_count; + }; +}; + +export const uploadFilters = () => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + filter: HttpStatus.LOADING, + }) + ); + + let filters: FilterFieldBase[]; + + try { + filters = await AnalysisAndTrainingApi.getFilter(); + } catch (e) { + dispatch( + setCardStatuses({ + filter: HttpStatus.FAILED, + }) + ); + return; + } + + dispatch(setFilters(filters)); + + dispatch( + setCardStatuses({ + filter: HttpStatus.FINISHED, + }) + ); + }; +}; + +export const updateFilters = (fields: FilterFieldBase[]) => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + filter: HttpStatus.LOADING, + frequentlyTerms: HttpStatus.LOADING, + defectSubmission: HttpStatus.LOADING, + statistic: HttpStatus.LOADING, + significantTerms: HttpStatus.LOADING, + }) + ); + + let response: { + filters: FilterFieldBase[]; + records_count: MainStatisticData + }; + + try { + response = await AnalysisAndTrainingApi.saveFilter({ + action: "apply", + filters: [ ...fields ], + }); + } catch (e) { + dispatch( + setCardStatuses({ + filter: HttpStatus.FAILED, + frequentlyTerms: HttpStatus.FINISHED, + defectSubmission: HttpStatus.FINISHED, + statistic: HttpStatus.FINISHED, + significantTerms: HttpStatus.FINISHED, + }) + ); + return; + } + + dispatch(setFilters(response.filters)); + dispatch(setTotalStatistic(response.records_count)); + + dispatch( + setCardStatuses({ + filter: HttpStatus.FINISHED, + }) + ); + + if (response.records_count.filtered) { + dispatch(uploadFrequentlyTerms()); + dispatch(uploadStatistic()); + dispatch(uploadDefectSubmission()); + dispatch(uploadSignificantTermsData()); + } else { + dispatch(addToast(FiltersPopUp.noDataFound, ToastStyle.Warning)); + + dispatch( + setCardStatuses({ + frequentlyTerms: HttpStatus.PREVIEW, + defectSubmission: HttpStatus.PREVIEW, + statistic: HttpStatus.PREVIEW, + significantTerms: HttpStatus.PREVIEW, + }) + ); + } + }; +}; + +export const uploadSignificantTermsData = () => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.LOADING, + }) + ); + + let significant_terms: SignificantTermsData; + + try { + significant_terms = await AnalysisAndTrainingApi.getSignificantTermsData(); + } catch (e) { + dispatch( + setCardWarnings({ + significantTerms: e.message, + }) + ); + + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.FAILED, + }) + ); + + return; + } + + dispatch(setSignificantTerms(significant_terms)); + + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.FINISHED, + }) + ); + }; +}; + +export const uploadSignificantTermsList = (metric: string) => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.LOADING, + }) + ); + + dispatch(updateSignificantTermsChosenMetric(metric)); + + let significant_terms: Terms; + + try { + significant_terms = await AnalysisAndTrainingApi.getSignificantTermsList(metric); + } catch (e) { + dispatch( + setCardWarnings({ + significantTerms: e.message, + }) + ); + + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.FAILED, + }) + ); + return; + } + + dispatch(updateSignificantTermsList(significant_terms)); + + dispatch( + setCardStatuses({ + significantTerms: HttpStatus.FINISHED, + }) + ); + }; +}; + +export const uploadDefectSubmission = (period?: string) => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + defectSubmission: HttpStatus.LOADING, + }) + ); + + let defectSubmission: DefectSubmissionData; + + try { + defectSubmission = await AnalysisAndTrainingApi.getDefectSubmission(period); + } catch (e) { + dispatch( + setCardStatuses({ + defectSubmission: HttpStatus.FAILED, + }) + ); + return; + } + + dispatch(setDefectSubmission(defectSubmission)); + dispatch( + setCardStatuses({ + defectSubmission: HttpStatus.FINISHED, + }) + ); + }; +}; + +export const uploadFrequentlyTerms = () => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + frequentlyTerms: HttpStatus.LOADING, + }) + ); + + let frequentlyTerms: string[]; + + try { + frequentlyTerms = await AnalysisAndTrainingApi.getFrequentlyTerms(); + } catch (e) { + dispatch( + setCardWarnings({ + frequentlyTerms: e.message, + }) + ); + + dispatch( + setCardStatuses({ + frequentlyTerms: HttpStatus.FAILED, + }) + ); + return; + } + + dispatch(setFrequentlyTerms(frequentlyTerms)); + + dispatch( + setCardStatuses({ + frequentlyTerms: HttpStatus.FINISHED, + }) + ); + }; +}; + +export const uploadStatistic = () => { + return async (dispatch: any) => { + dispatch( + setCardStatuses({ + statistic: HttpStatus.LOADING, + }) + ); + + let statistic: AnalysisAndTrainingStatistic; + + try { + statistic = await AnalysisAndTrainingApi.getStatistic(); + } catch (e) { + dispatch( + setCardStatuses({ + statistic: HttpStatus.FAILED, + }) + ); + return; + } + + dispatch(setStatistic(statistic)); + + dispatch( + setCardStatuses({ + statistic: HttpStatus.FINISHED, + }) + ); + }; +}; diff --git a/frontend/src/app/common/store/analysis-and-training/types.ts b/frontend/src/app/common/store/analysis-and-training/types.ts new file mode 100644 index 0000000..14c395f --- /dev/null +++ b/frontend/src/app/common/store/analysis-and-training/types.ts @@ -0,0 +1,31 @@ +import { + AnalysisAndTrainingStatistic, DefectSubmissionData, + SignificantTermsData +} from "app/common/types/analysis-and-training.types"; +import { HttpStatus } from "app/common/types/http.types"; +import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { MainStatisticData } from "app/modules/main-statistic/main-statistic"; + +export interface AnalysisAndTrainingStatuses { + filter: HttpStatus; + frequentlyTerms: HttpStatus; + defectSubmission: HttpStatus; + statistic: HttpStatus; + significantTerms: HttpStatus; +}; + +export interface AnalysisAndTrainingWarnings { + frequentlyTerms: string; + significantTerms: string; +}; + +export interface AnalysisAndTrainingStore { + filters: FilterFieldBase[]; + totalStatistic: MainStatisticData | undefined; + frequentlyTerms: string[]; + statistic: AnalysisAndTrainingStatistic; + significantTerms: SignificantTermsData; + defectSubmission: DefectSubmissionData; + statuses: AnalysisAndTrainingStatuses; + warnings: AnalysisAndTrainingWarnings; +} diff --git a/frontend/src/app/common/store/auth/actions.ts b/frontend/src/app/common/store/auth/actions.ts index 4714c5a..9590c84 100644 --- a/frontend/src/app/common/store/auth/actions.ts +++ b/frontend/src/app/common/store/auth/actions.ts @@ -1,5 +1,5 @@ import { removeData } from "app/common/functions/local-storage"; -import { Team, User } from "app/common/types/user.types"; +import { User } from "app/common/types/user.types"; import { HttpStatus } from "app/common/types/http.types"; export const setUser = (user: User) => @@ -21,9 +21,3 @@ export const setStatus = (status: HttpStatus) => type: "ACTION_AUTH_SET_STATUS", status, } as const); - -export const setTeamList = (teamList: Team[]) => - ({ - type: "ACTION_AUTH_SET_TEAM_LIST", - teamList, - } as const); diff --git a/frontend/src/app/common/store/auth/reducers.ts b/frontend/src/app/common/store/auth/reducers.ts index fef0d3d..ac0c570 100644 --- a/frontend/src/app/common/store/auth/reducers.ts +++ b/frontend/src/app/common/store/auth/reducers.ts @@ -8,7 +8,6 @@ import * as actions from "./actions"; const initialState: AuthStore = { status: HttpStatus.PREVIEW, user: getDataFromLocalStorage("user"), - teamList: [], }; type actionsUserTypes = ReturnType>; @@ -33,12 +32,6 @@ export const authReducer = (state: AuthStore = initialState, action: actionsUser status: action.status, }; - case "ACTION_AUTH_SET_TEAM_LIST": - return { - ...state, - teamList: [...action.teamList], - }; - default: return state; } diff --git a/frontend/src/app/common/store/auth/thunks.ts b/frontend/src/app/common/store/auth/thunks.ts index ce8ae88..15b3802 100644 --- a/frontend/src/app/common/store/auth/thunks.ts +++ b/frontend/src/app/common/store/auth/thunks.ts @@ -2,14 +2,33 @@ import { AuthApi } from "app/common/api/auth.api"; import HttpClient from "app/common/api/http-client"; import { deleteExtraSpaces } from "app/common/functions/helper"; import { saveDataToLocalStorage } from "app/common/functions/local-storage"; -import { setStatus, setTeamList, setUser } from "app/common/store/auth/actions"; -import { HttpStatus } from "app/common/types/http.types"; +import { deleteUser, setStatus, setUser } from "app/common/store/auth/actions"; +import { resetCommonStatuses } from "app/common/store/common/actions"; +import { clearQAMetricsData } from "app/common/store/qa-metrics/actions"; +import { clearSettingsData } from "app/common/store/settings/thunks"; +import { clearMessages } from "app/common/store/virtual-assistant/actions"; +import { + HTTPFieldValidationError, + HttpStatus +} from "app/common/types/http.types"; import { RouterNames } from "app/common/types/router.types"; import { User, UserSignIn, UserSignUp } from "app/common/types/user.types"; import { addToast } from "app/modules/toasts-overlay/store/actions"; import { ToastStyle } from "app/modules/toasts-overlay/store/types"; import { push } from "connected-react-router"; +export const verifyToken = (token: string) => { + return async (dispatch: any) => { + try { + await AuthApi.getUserId(token); + } catch (e) { + dispatch(logout()); + dispatch(addToast(e.detail, ToastStyle.Error)); + return; + } + }; +}; + export const userSignIn = (signInData: UserSignIn) => { return async (dispatch: any) => { dispatch(setStatus(HttpStatus.LOADING)); @@ -21,7 +40,7 @@ export const userSignIn = (signInData: UserSignIn) => { dispatch(setStatus(HttpStatus.FAILED)); return; } - saveDataToLocalStorage('user', user); + saveDataToLocalStorage("user", user); HttpClient.token = user.token; dispatch(setUser(user)); dispatch(push(RouterNames.analysisAndTraining)); @@ -37,26 +56,30 @@ export const userSignUp = (signUpData: UserSignUp) => { try { await AuthApi.signUp(signUpData); } catch (e) { - e.detailArr.forEach((error: any) => { dispatch(addToast(error, ToastStyle.Error)); }); + e.fields.forEach((field: HTTPFieldValidationError) => { + field.errors.forEach(validationError => { + dispatch(addToast(validationError , ToastStyle.Error)); + }); + }); + dispatch(setStatus(HttpStatus.FAILED)); return; } - dispatch(addToast('Registration completed successfully', ToastStyle.Success)); + dispatch(addToast("Registration completed successfully", ToastStyle.Success)); dispatch(push(RouterNames.signIn)); dispatch(setStatus(HttpStatus.FINISHED)); }; }; -export const getTeamList = () => { +export const logout = () => { return async (dispatch: any) => { - let res; - try{ - res = await AuthApi.getTeamList(); - } - catch(e){ - dispatch(addToast(e.detail, ToastStyle.Error)); - return; - } - dispatch(setTeamList(res)); + HttpClient.token = ''; + + dispatch(deleteUser()); + dispatch(clearQAMetricsData()); + dispatch(clearSettingsData()); + dispatch(clearMessages()); + dispatch(resetCommonStatuses()); + dispatch(push(RouterNames.auth)); }; }; diff --git a/frontend/src/app/common/store/auth/types.ts b/frontend/src/app/common/store/auth/types.ts index 1e81bc6..26279bf 100644 --- a/frontend/src/app/common/store/auth/types.ts +++ b/frontend/src/app/common/store/auth/types.ts @@ -1,8 +1,7 @@ import { HttpStatus } from "app/common/types/http.types"; -import { Team, User } from "app/common/types/user.types"; +import { User } from "app/common/types/user.types"; export interface AuthStore { status: HttpStatus; user: User | null; - teamList: Team[]; } diff --git a/frontend/src/app/common/store/common/actions.ts b/frontend/src/app/common/store/common/actions.ts index a0ef4eb..3bc8995 100644 --- a/frontend/src/app/common/store/common/actions.ts +++ b/frontend/src/app/common/store/common/actions.ts @@ -1,3 +1,10 @@ -export const markLoadIssuesFinished = () => ({ - type: 'MARK_LOAD_ISSUES_FINISHED' +import { CommonStore } from "app/common/store/common/types"; + +export const updateCommonStatuses = (statuses: Partial) => ({ + type: 'UPDATE_COMMON_STATUSES', + statuses +} as const) + +export const resetCommonStatuses = () => ({ + type: 'RESET_COMMON_STATUSES', } as const) diff --git a/frontend/src/app/common/store/common/reducers.ts b/frontend/src/app/common/store/common/reducers.ts index 03b3ba4..c4e8d84 100644 --- a/frontend/src/app/common/store/common/reducers.ts +++ b/frontend/src/app/common/store/common/reducers.ts @@ -3,8 +3,10 @@ import { InferValueTypes } from "app/common/store/utils"; import * as actions from "./actions"; const initialState: CommonStore = { - isCollectingFinished: false, - isTrainFinished: false, + isLoadedIssuesStatus: false, + isIssuesExist: false, + isSearchingModelFinished: false, + isModelFounded: false, }; type actionsUserTypes = ReturnType>; @@ -13,12 +15,17 @@ export const commonReducer = (state: CommonStore = initialState, action: actions switch (action.type) { - case 'MARK_LOAD_ISSUES_FINISHED': + case 'UPDATE_COMMON_STATUSES': return { ...state, - isCollectingFinished: true, + ...action.statuses, }; + case 'RESET_COMMON_STATUSES': + return { + ...initialState + }; + default: return { ...state }; } diff --git a/frontend/src/app/common/store/common/thunks.ts b/frontend/src/app/common/store/common/thunks.ts index af53543..7a9e920 100644 --- a/frontend/src/app/common/store/common/thunks.ts +++ b/frontend/src/app/common/store/common/thunks.ts @@ -1,14 +1,75 @@ -import { AnalysisAndTrainingApi } from 'app/common/api/analysis-and-training.api'; -import { markLoadIssuesFinished } from 'app/common/store/common/actions'; -import { InitialApiResponse } from './types'; +import { AnalysisAndTrainingApi } from "app/common/api/analysis-and-training.api"; +import { updateCommonStatuses } from "app/common/store/common/actions"; +import { clearQAMetricsData } from "app/common/store/qa-metrics/actions"; +import { setTrainingStatus } from "app/common/store/traininig/actions"; +import { HttpStatus } from "app/common/types/http.types"; +import { clearPageData as clearDAPageData } from "app/common/store/description-assessment/actions"; +import { InitialApiResponse } from "./types"; -export const checkCollectingIssuesFinished = () => { +export const checkIssuesStatus = () => { return async (dispatch: any) => { + dispatch( + updateCommonStatuses({ + isLoadedIssuesStatus: false, + }) + ); + + let issuesStatus: InitialApiResponse; + + try { + issuesStatus = await AnalysisAndTrainingApi.getCollectingDataStatus(); + } catch (e) { + return; + } + + dispatch( + updateCommonStatuses({ + isLoadedIssuesStatus: true, + isIssuesExist: issuesStatus.issues_exists, + }) + ); + + }; +}; + +export const searchTrainedModel = () => { + return async (dispatch: any) => { + dispatch( + updateCommonStatuses({ + isSearchingModelFinished: false, + }) + ); + + let isTrainedModel: boolean; + try { - const dataCollectingStatus: InitialApiResponse = await AnalysisAndTrainingApi.getGeneralApplicationStatus(); - if (dataCollectingStatus.issues_exists) dispatch(markLoadIssuesFinished()); + isTrainedModel = await AnalysisAndTrainingApi.getTrainingModelStatus(); } catch (e) { return; - } - } -} + } + + + dispatch(setTrainingStatus(isTrainedModel ? HttpStatus.FINISHED : HttpStatus.PREVIEW)); + + dispatch( + updateCommonStatuses({ + isSearchingModelFinished: true, + isModelFounded: isTrainedModel, + }) + ); + }; +}; + +export const markModelNotTrained = () => { + return async (dispatch: any) => { + dispatch( + updateCommonStatuses({ + isSearchingModelFinished: true, + isModelFounded: false, + }) + ); + dispatch(setTrainingStatus(HttpStatus.PREVIEW)) + dispatch(clearDAPageData()); + dispatch(clearQAMetricsData()); + }; +}; diff --git a/frontend/src/app/common/store/common/types.ts b/frontend/src/app/common/store/common/types.ts index a00d672..cd52430 100644 --- a/frontend/src/app/common/store/common/types.ts +++ b/frontend/src/app/common/store/common/types.ts @@ -1,8 +1,10 @@ export interface CommonStore { - isCollectingFinished: boolean; - isTrainFinished: boolean; + isLoadedIssuesStatus: boolean; + isIssuesExist: boolean; + isSearchingModelFinished: boolean; + isModelFounded: boolean; } -export interface InitialApiResponse { - issues_exists: boolean +export interface InitialApiResponse { + issues_exists: boolean; } diff --git a/frontend/src/app/common/store/common/utils.ts b/frontend/src/app/common/store/common/utils.ts new file mode 100644 index 0000000..ed7ee97 --- /dev/null +++ b/frontend/src/app/common/store/common/utils.ts @@ -0,0 +1,40 @@ +import store from "app/common/store/configureStore"; +import { Unsubscribe } from "redux"; + +export function checkIssuesExist(): Promise { + return new Promise((resolve) => { + + let unsubscribeStore: Unsubscribe; + + if (store.getState().common.isLoadedIssuesStatus) { + resolve(store.getState().common.isIssuesExist) + } else { + unsubscribeStore = store.subscribe(() => { + if (store.getState().common.isLoadedIssuesStatus) { + resolve(store.getState().common.isIssuesExist) + unsubscribeStore() + } + }) + } + + }) +} + +export function checkModelIsFound(): Promise { + return new Promise((resolve) => { + + let unsubscribeStore: Unsubscribe; + + if (store.getState().common.isSearchingModelFinished) { + resolve(store.getState().common.isModelFounded) + } else { + unsubscribeStore = store.subscribe(() => { + if (store.getState().common.isSearchingModelFinished) { + resolve(store.getState().common.isModelFounded) + unsubscribeStore() + } + }) + } + + }) +} diff --git a/frontend/src/app/common/store/configureStore.ts b/frontend/src/app/common/store/configureStore.ts index 58ae33c..8a476b0 100644 --- a/frontend/src/app/common/store/configureStore.ts +++ b/frontend/src/app/common/store/configureStore.ts @@ -1,16 +1,19 @@ -import { commonReducer } from "app/common/store/common/reducers"; -import { qaMetricsPageReducer } from "app/common/store/qa-metrics/reducers"; -import { toastsReducers } from "app/modules/toasts-overlay/store/reducer"; -import { applyMiddleware, combineReducers, createStore } from "redux"; -import thunk from "redux-thunk"; -import { composeWithDevTools } from "redux-devtools-extension"; -import { connectRouter, routerMiddleware } from "connected-react-router"; +import { analysisAndTrainingReducers } from "app/common/store/analysis-and-training/reducers"; +import descriptionAssessmentReducer from "app/common/store/description-assessment/reducer"; import { authReducer } from "app/common/store/auth/reducers"; +import { commonReducer } from "app/common/store/common/reducers"; +import { qaMetricsPageReducer } from "app/common/store/qa-metrics/reducers"; import { generalSettingsStore } from "app/common/store/settings/reducers"; +import { trainingReducers } from "app/common/store/traininig/reducers"; import virtualAssistantReducer from "app/common/store/virtual-assistant/reducers"; -import { createBrowserHistory, History } from "history"; import { RootStore } from "app/common/types/store.types"; +import { toastsReducers } from "app/modules/toasts-overlay/store/reducer"; +import { connectRouter, routerMiddleware } from "connected-react-router"; +import { createBrowserHistory, History } from "history"; +import { applyMiddleware, combineReducers, createStore } from "redux"; +import { composeWithDevTools } from "redux-devtools-extension"; +import thunk from "redux-thunk"; export const history = createBrowserHistory(); @@ -21,8 +24,11 @@ const rootReducers = (history: History) => settings: generalSettingsStore, virtualAssistant: virtualAssistantReducer, qaMetricsPage: qaMetricsPageReducer, - router: connectRouter(history), common: commonReducer, + analysisAndTraining: analysisAndTrainingReducers, + descriptionAssessment: descriptionAssessmentReducer, + training: trainingReducers, + router: connectRouter(history), }); function configureStore(preloadedState?: RootStore) { diff --git a/frontend/src/app/common/store/description-assessment/actions.ts b/frontend/src/app/common/store/description-assessment/actions.ts new file mode 100644 index 0000000..131605b --- /dev/null +++ b/frontend/src/app/common/store/description-assessment/actions.ts @@ -0,0 +1,43 @@ +import { HttpStatus } from "app/common/types/http.types"; +import { PredictMetricsName } from "app/modules/predict-text/predict-text"; +import { + DAPrioritySortBy, + DescriptionAssessmentActionTypes, + Keywords, + Probabilities +} from "./types"; + +export const setStatus = (status: HttpStatus) => ({ + status, + type: DescriptionAssessmentActionTypes.setStatus +} as const) + +export const clearPageData = () => ({ + type: DescriptionAssessmentActionTypes.clearPredictionText +} as const) + +export const setDAText = (text: string) => ({ + text, + type: DescriptionAssessmentActionTypes.setText +} as const) + +export const setKeywords = (metricName: PredictMetricsName, keyWords: string[]) => ({ + keyWords, + metricName, + type: DescriptionAssessmentActionTypes.setKeywords +} as const) + +export const setMetrics = (metrics: Keywords) => ({ + metrics, + type: DescriptionAssessmentActionTypes.setMetrics +} as const) + +export const setProbabilities = (probabilities: Probabilities) => ({ + probabilities, + type: DescriptionAssessmentActionTypes.setProbabilities +} as const) + +export const sortDAPriority = (sortBy: DAPrioritySortBy) => ({ + sortBy, + type: DescriptionAssessmentActionTypes.sortPriority, + } as const); diff --git a/frontend/src/app/common/store/description-assessment/reducer.ts b/frontend/src/app/common/store/description-assessment/reducer.ts new file mode 100644 index 0000000..28df0da --- /dev/null +++ b/frontend/src/app/common/store/description-assessment/reducer.ts @@ -0,0 +1,85 @@ +import { ChartData } from "app/common/components/charts/types"; +import { InferValueTypes } from "app/common/store/utils"; +import { HttpStatus } from "app/common/types/http.types"; +import * as actions from "./actions"; +import { + DAPrioritySortBy, + DescriptionAssessmentActionTypes, + DescriptionAssessmentStore, +} from "./types"; + +const initialState: DescriptionAssessmentStore = { + status: HttpStatus.PREVIEW, + text: '', + metrics: { + Priority: [], + resolution: [], + areas_of_testing: [], + }, + keywords: { + Priority: [], + resolution: [], + areas_of_testing: [], + }, + probabilities: null, +}; + + +type actionsUserTypes = ReturnType>; + +export default function descriptionAssessmentReducer(state: DescriptionAssessmentStore = initialState, action: actionsUserTypes) { + switch (action.type) { + case DescriptionAssessmentActionTypes.setStatus: + return { ...state, status: action.status }; + + case DescriptionAssessmentActionTypes.setKeywords: + const keywords = { ...state.keywords }; + keywords[action.metricName] = [...action.keyWords]; + return { ...state, keywords }; + + case DescriptionAssessmentActionTypes.setMetrics: + return { ...state, metrics: action.metrics }; + + case DescriptionAssessmentActionTypes.setProbabilities: + return { + ...state, + probabilities: { + ...action.probabilities, + Priority: [...sortPriority(action.probabilities.Priority, DAPrioritySortBy.Value)], + }, + }; + + case DescriptionAssessmentActionTypes.setText: + return { + ...state, + text: action.text + } + + case DescriptionAssessmentActionTypes.sortPriority: + return { + ...state, + probabilities: { + ...state.probabilities!, + Priority: [ ...sortPriority(state.probabilities!.Priority, action.sortBy)] + }, + }; + + case DescriptionAssessmentActionTypes.clearPredictionText: + return { ...initialState }; + + default: + return { ...state }; + } +} + +function sortPriority( + chartData: ChartData, + fieldName: DAPrioritySortBy +): ChartData { + return chartData.sort((a, b) => { + if (fieldName === DAPrioritySortBy.Value) { + return a[fieldName] < b[fieldName] ? 1 : -1; + } + return a[fieldName] < b[fieldName] ? -1 : 1; + }) +} diff --git a/frontend/src/app/common/store/description-assessment/thunk.ts b/frontend/src/app/common/store/description-assessment/thunk.ts new file mode 100644 index 0000000..190bbcc --- /dev/null +++ b/frontend/src/app/common/store/description-assessment/thunk.ts @@ -0,0 +1,71 @@ +import { DescriptionAssessmentApi } from "app/common/api/description-assessment.api"; +import { fixTTRBarChartAxisDisplayStyle } from "app/common/functions/helper"; +import { HttpError, HttpStatus } from "app/common/types/http.types"; +import { PredictMetric } from "app/modules/predict-text/predict-text"; +import { addToast } from "app/modules/toasts-overlay/store/actions"; +import { ToastStyle } from "app/modules/toasts-overlay/store/types"; +import { setStatus, setKeywords, setMetrics, setProbabilities, setDAText } from "./actions"; +import { Probabilities } from "./types"; + +export function getKeywords(predictMetric: PredictMetric) { + return async (dispatch: any) => { + + // for reset keywords + try { + let newKeywords: string[]; + if (predictMetric.value) { + newKeywords = await DescriptionAssessmentApi.getHighlightedTerms(predictMetric); + } else { + newKeywords = []; + } + dispatch(setKeywords(predictMetric.metric, newKeywords)) + + } catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); + } + } +} + +export function getMetrics(empty: boolean = false) { + return async (dispatch: any) => { + try { + const metrics = await DescriptionAssessmentApi.getMetrics(); + if (metrics.warning) { + addToast(metrics.warning.detail, ToastStyle.Warning); + return; + } + if (!empty) { + dispatch(setMetrics(metrics)); + } + + } catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); + } + } +} + +export function predictText(text: string) { + return async (dispatch: any) => { + + dispatch(setDAText(text)); + dispatch(setStatus(HttpStatus.LOADING)); + + try { + const data = await DescriptionAssessmentApi.predictText(text); + + if (data.warning) throw new Error(data.warning.detail || data.warning.message); + + dispatch(setStatus(HttpStatus.FINISHED)) + + const probabilities: Probabilities = data; + probabilities["Time to Resolve"] = fixTTRBarChartAxisDisplayStyle(probabilities["Time to Resolve"]); + + dispatch(setProbabilities(probabilities)); + dispatch(getMetrics()) + + } catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); + dispatch(setStatus(HttpStatus.FAILED)) + } + } +} diff --git a/frontend/src/app/common/store/description-assessment/types.ts b/frontend/src/app/common/store/description-assessment/types.ts new file mode 100644 index 0000000..6307e9d --- /dev/null +++ b/frontend/src/app/common/store/description-assessment/types.ts @@ -0,0 +1,39 @@ +import { ChartData, ChartsList } from "app/common/components/charts/types"; +import { HttpStatus } from "app/common/types/http.types"; +import { Terms } from "app/modules/significant-terms/store/types"; + +export interface Probabilities { + resolution: ChartsList; + areas_of_testing: Terms; + "Time to Resolve": ChartData; + Priority: ChartData; +} + +export interface Keywords { + Priority: string[]; + resolution: string[]; + areas_of_testing: string[]; +} + +export enum DAPrioritySortBy { + Value = 'value', + Name = 'name' +} + +export interface DescriptionAssessmentStore { + text: string; + status: HttpStatus; + metrics: Keywords; + keywords: Keywords; + probabilities: Probabilities | null; +} + +export enum DescriptionAssessmentActionTypes { + setStatus = "SET_STATUS", + clearPredictionText = "CLEAR_PREDICTION_TEXT", + setKeywords = "SET_KEYWORDS", + setText = "SET_TEXT", + setMetrics = "SET_METRICS", + setProbabilities = "SET_PROBABILITIES", + sortPriority = "DA_SORT_PRIORITY" +} diff --git a/frontend/src/app/common/store/qa-metrics/actions.ts b/frontend/src/app/common/store/qa-metrics/actions.ts index 6649b94..e836e85 100644 --- a/frontend/src/app/common/store/qa-metrics/actions.ts +++ b/frontend/src/app/common/store/qa-metrics/actions.ts @@ -1,31 +1,15 @@ import { - QAMetricsData, - QAMetricsResolutionChartData, - QAMetricsStorePart, - QAMetricsRecordsCount, + QAMetricsAllData, QAMetricsPrioritySortBy, + QAMetricsRecordsCount, QAMetricsStatuses } from "app/common/store/qa-metrics/types"; -import { HttpStatus } from "app/common/types/http.types"; - -export interface QAMetricsAllData { - predictions_table: QAMetricsData[]; - prediction_table_rows_count: number; - areas_of_testing_chart: QAMetricsData; - priority_chart: QAMetricsData; - ttr_chart: QAMetricsData; - resolution_chart: QAMetricsResolutionChartData; -} - -export const setStatusTrainModelQAMetrics = (newModelStatus: boolean) => - ({ - type: "SET_STATUS_TRAIN_MODEL_QA_METRICS", - newModelStatus, - } as const); +import { ObjectWithUnknownFields } from "app/common/types/http.types"; +import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { getFieldEmptyValue, setFieldValue } from "app/modules/filters/field/field.helper-function"; -export const setQaMetricsStatus = (part: QAMetricsStorePart, newStatus: HttpStatus) => +export const setQaMetricsStatuses = (statuses: Partial) => ({ type: "SET_QA_METRICS_PAGE_STATUS", - newStatus, - part, + statuses } as const); export const setQaMetricsRecordsCount = (records_count: QAMetricsRecordsCount) => @@ -34,18 +18,39 @@ export const setQaMetricsRecordsCount = (records_count: QAMetricsRecordsCount) = records_count, } as const); +export const setQaMetricsFilter = (fields: FilterFieldBase[]) => + ({ + type: "SET_QA_METRICS_FILTER", + fields: // cause api don't return "current_value" property for unfilled field + fields.map((field: FilterFieldBase) => ({ + exact_match: false, + current_value: setFieldValue( + field.type, + field.current_value || getFieldEmptyValue(field.type) + ), + ...field, + })) + } as const); + + export const setQAMetricsAllData = (data: QAMetricsAllData) => ({ type: "SET_QA_METRICS_ALL_DATA", data, } as const); -export const setQAMetricsTable = (tableData: QAMetricsData[]) => +export const setQAMetricsTable = (tableData: ObjectWithUnknownFields[]) => ({ type: "SET_QA_METRICS_PAGE_TABLE", tableData, } as const); +export const changeQAMetricsPrioritySortBy = (sortBy: QAMetricsPrioritySortBy) => + ({ + sortBy, + type: "CHANGE_SORT_BY_QA_METRICS_PRIORITY", + } as const); + export const clearQAMetricsData = () => ({ type: "CLEAR_QA_METRICS_DATA", diff --git a/frontend/src/app/common/store/qa-metrics/reducers.ts b/frontend/src/app/common/store/qa-metrics/reducers.ts index 5b884ba..2eecc5c 100644 --- a/frontend/src/app/common/store/qa-metrics/reducers.ts +++ b/frontend/src/app/common/store/qa-metrics/reducers.ts @@ -1,15 +1,17 @@ -import { QAMetricsStore } from "app/common/store/qa-metrics/types"; +import { ChartData } from "app/common/components/charts/types"; +import { QAMetricsPrioritySortBy, QAMetricsStore } from "app/common/store/qa-metrics/types"; import { InferValueTypes } from "app/common/store/utils"; import { HttpStatus } from "app/common/types/http.types"; import { fixTTRBarChartAxisDisplayStyle, - fixTTRPredictionTableDisplayStyle, + fixTTRPredictionTableDisplayStyle } from "app/common/functions/helper"; import * as actions from "./actions"; const initialState: QAMetricsStore = { + filter: [], statuses: { - filters: HttpStatus.PREVIEW, + filter: HttpStatus.PREVIEW, data: HttpStatus.PREVIEW, table: HttpStatus.PREVIEW, }, @@ -17,41 +19,41 @@ const initialState: QAMetricsStore = { total: 0, filtered: 0, }, - isModelTrained: true, predictions_table: [], prediction_table_rows_count: 0, areas_of_testing_chart: {}, - priority_chart: {}, - ttr_chart: {}, - resolution_chart: {}, + priority_chart: [], + ttr_chart: [], + resolution_chart: [], }; -type actionsQAMetricsTypes = ReturnType>; +type actionsTypes = ReturnType>; export const qaMetricsPageReducer = ( state: QAMetricsStore = initialState, - action: actionsQAMetricsTypes + action: actionsTypes ): QAMetricsStore => { switch (action.type) { + case "SET_QA_METRICS_PAGE_STATUS": return { ...state, statuses: { ...state.statuses, - [action.part]: action.newStatus, + ...action.statuses }, }; - case "SET_STATUS_TRAIN_MODEL_QA_METRICS": + case "SET_QA_METRICS_RECORDS_COUNT": return { ...state, - isModelTrained: action.newModelStatus, + records_count: { ...action.records_count }, }; - case "SET_QA_METRICS_RECORDS_COUNT": + case "SET_QA_METRICS_FILTER": return { ...state, - records_count: { ...action.records_count }, + filter: [ ...action.fields ], }; case "SET_QA_METRICS_ALL_DATA": @@ -60,9 +62,9 @@ export const qaMetricsPageReducer = ( predictions_table: [...fixTTRPredictionTableDisplayStyle(action.data.predictions_table)], prediction_table_rows_count: action.data.prediction_table_rows_count, areas_of_testing_chart: { ...action.data.areas_of_testing_chart }, - priority_chart: { ...action.data.priority_chart }, - ttr_chart: { ...fixTTRBarChartAxisDisplayStyle(action.data.ttr_chart) }, - resolution_chart: { ...action.data.resolution_chart }, + priority_chart: [ ...sortPriority(action.data.priority_chart, QAMetricsPrioritySortBy.Value)], + ttr_chart: [ ...fixTTRBarChartAxisDisplayStyle(action.data.ttr_chart) ], + resolution_chart: [ ...action.data.resolution_chart ] , }; case "SET_QA_METRICS_PAGE_TABLE": @@ -71,6 +73,12 @@ export const qaMetricsPageReducer = ( predictions_table: [...fixTTRPredictionTableDisplayStyle(action.tableData)], }; + case "CHANGE_SORT_BY_QA_METRICS_PRIORITY": + return { + ...state, + priority_chart: [ ...sortPriority(state.priority_chart, action.sortBy)], + }; + case "CLEAR_QA_METRICS_DATA": return { ...initialState }; @@ -78,3 +86,15 @@ export const qaMetricsPageReducer = ( return state; } }; + +function sortPriority( + chartData: ChartData, + fieldName: QAMetricsPrioritySortBy +): ChartData { + return chartData.sort((a, b) => { + if (fieldName === QAMetricsPrioritySortBy.Value) { + return a[fieldName] < b[fieldName] ? 1 : -1; + } + return a[fieldName] < b[fieldName] ? -1 : 1; + }) +} diff --git a/frontend/src/app/common/store/qa-metrics/thunks.ts b/frontend/src/app/common/store/qa-metrics/thunks.ts index f59f6e5..d18ceac 100644 --- a/frontend/src/app/common/store/qa-metrics/thunks.ts +++ b/frontend/src/app/common/store/qa-metrics/thunks.ts @@ -1,185 +1,253 @@ import QaMetricsApi from "app/common/api/qa-metrics.api"; +import { checkModelIsFound } from "app/common/store/common/utils"; import { setQAMetricsAllData, + setQaMetricsFilter, setQaMetricsRecordsCount, - setQaMetricsStatus, + setQaMetricsStatuses, setQAMetricsTable, - setStatusTrainModelQAMetrics, } from "app/common/store/qa-metrics/actions"; -import { HttpError, HttpStatus } from "app/common/types/http.types"; +import { + QAMetricsRecordsCount, + QAMetricsAllData +} from "app/common/store/qa-metrics/types"; +import { HttpError, HttpStatus, ObjectWithUnknownFields } from "app/common/types/http.types"; import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { FiltersPopUp } from "app/modules/filters/filters"; import { addToast } from "app/modules/toasts-overlay/store/actions"; import { ToastStyle } from "app/modules/toasts-overlay/store/types"; -import { - checkFieldIsFilled, - getFieldEmptyValue, - setFieldValue, -} from "app/modules/filters/field/field.helper-function"; -import { FiltersPopUp } from "app/modules/filters/filters"; -export const initQAMetrics = () => { +export const getQAMetricsData = () => { return async (dispatch: any) => { - dispatch(setQaMetricsStatus("filters", HttpStatus.LOADING)); + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.LOADING, + data: HttpStatus.LOADING, + table: HttpStatus.LOADING, + }) + ); + + let records_count: QAMetricsRecordsCount; + + if (await checkModelIsFound()) { + dispatch(uploadQAMetricsFilters()); + records_count = await dispatch(getQAMetricsTotalStatistic()); + } else { + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.PREVIEW, + data: HttpStatus.PREVIEW, + table: HttpStatus.PREVIEW, + }) + ); + return; + } - let countsRes; - let filtersRes; + if (records_count.filtered) { + dispatch(updateQAMetricsData()); + } else { + dispatch( + addToast( + "With cached filters we didn't find data. Try to change filter.", + ToastStyle.Warning + ) + ); + + dispatch( + setQaMetricsStatuses({ + data: HttpStatus.PREVIEW, + table: HttpStatus.PREVIEW, + }) + ); + } + }; +}; + +export const getQAMetricsTotalStatistic = () => { + return async (dispatch: any) => { + let records_count: QAMetricsRecordsCount; try { - countsRes = await QaMetricsApi.getCount(); - filtersRes = await QaMetricsApi.getFilters(); + records_count = await QaMetricsApi.getCount(); } catch (e) { - dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); - dispatch(setQaMetricsStatus("filters", HttpStatus.FAILED)); return; } - const recordsCountCode = countsRes.status; - const filtersResCode = filtersRes.status; + dispatch(setQaMetricsRecordsCount(records_count)); - const recordsCountResBody = await countsRes.json(); - const filtersResBody = await filtersRes.json(); + return records_count; + }; +}; - dispatch(setQaMetricsStatus("filters", HttpStatus.FINISHED)); +export const uploadQAMetricsFilters = () => { + return async (dispatch: any) => { + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.LOADING, + }) + ); - if (recordsCountCode === 209 || filtersResCode === 209) { - const warning = recordsCountResBody.warning || filtersResBody.warning; - dispatch(setQaMetricsStatus("filters", HttpStatus.PREVIEW)); - dispatch(setStatusTrainModelQAMetrics(false)); - dispatch(addToast(warning.detail || warning.message, ToastStyle.Warning)); - return []; - } + let fields: FilterFieldBase[]; - if (!recordsCountResBody.records_count.filtered) { - dispatch(setQaMetricsStatus("data", HttpStatus.FAILED)); + try { + fields = await QaMetricsApi.getFilters(); + } catch (e) { + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.FAILED, + }) + ); + return; } - dispatch(setQaMetricsRecordsCount(recordsCountResBody.records_count)); - dispatch(setStatusTrainModelQAMetrics(true)); - - return filtersResBody.map((field: FilterFieldBase) => ({ - ...field, - exact_match: false, - current_value: setFieldValue( - field.filtration_type, - field.current_value || getFieldEmptyValue(field.filtration_type) - ), - })); + dispatch(setQaMetricsFilter(fields)); + + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.FINISHED, + }) + ); }; }; -export const saveQAMetricsFilters = (filters: FilterFieldBase[]) => { +export const applyQAMetricsFilters = (filters: FilterFieldBase[]) => { return async (dispatch: any) => { - dispatch(setQaMetricsStatus("filters", HttpStatus.LOADING)); + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.LOADING, + data: HttpStatus.LOADING, + table: HttpStatus.RELOADING, + }) + ); - let response; + let records_count: QAMetricsRecordsCount; try { - response = await QaMetricsApi.saveFilters([ - ...filters.filter((field) => - checkFieldIsFilled(field.filtration_type, field.current_value) - ), - ]); + records_count = await dispatch(saveQAMetricsFilters(filters)); } catch (e) { dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); - dispatch(setQaMetricsStatus("filters", HttpStatus.FAILED)); + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.FAILED, + }) + ); return; } - const code = response.status; - const body = await response.json(); + if (records_count.filtered) { + dispatch(updateQAMetricsData()); + } else { + dispatch( + setQaMetricsStatuses({ + data: HttpStatus.PREVIEW, + table: HttpStatus.PREVIEW, + }) + ); - dispatch(setQaMetricsStatus("filters", HttpStatus.FINISHED)); - - if (code === 209) { - dispatch(setQaMetricsStatus("filters", HttpStatus.PREVIEW)); - dispatch(setStatusTrainModelQAMetrics(false)); - dispatch(addToast(body.warning.detail || body.warning.message, ToastStyle.Warning)); - return []; + dispatch(addToast(FiltersPopUp.noDataFound, ToastStyle.Warning)); } + }; +}; - if (body.warning) { - dispatch(setQaMetricsStatus("filters", HttpStatus.PREVIEW)); - dispatch(setStatusTrainModelQAMetrics(false)); - return []; - } +export const saveQAMetricsFilters = (filters: FilterFieldBase[]) => { + return async (dispatch: any) => { + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.LOADING, + }) + ); - // check, that bugs is founded - if (!body.records_count.filtered) { - dispatch(setQaMetricsStatus("data", HttpStatus.FAILED)); - dispatch(addToast(FiltersPopUp.noDataFound, ToastStyle.Warning)); + let response: { + records_count: QAMetricsRecordsCount; + filters: FilterFieldBase[]; + }; + + try { + response = await QaMetricsApi.saveFilters([...filters]); + } catch (e) { + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.FAILED, + }) + ); + return; } - dispatch(setStatusTrainModelQAMetrics(true)); - dispatch(setQaMetricsRecordsCount(body.records_count)); + dispatch(setQaMetricsRecordsCount(response.records_count)); + dispatch(setQaMetricsFilter(response.filters)); - const newFilters = body.filters.map((field: FilterFieldBase) => ({ - ...field, - exact_match: false, - current_value: setFieldValue( - field.filtration_type, - getFieldEmptyValue(field.filtration_type) - ), - })); + dispatch( + setQaMetricsStatuses({ + filter: HttpStatus.FINISHED, + }) + ); - return newFilters; + return response.records_count; }; }; export const updateQAMetricsData = () => { return async (dispatch: any) => { - dispatch(setQaMetricsStatus("data", HttpStatus.LOADING)); + dispatch( + setQaMetricsStatuses({ + data: HttpStatus.LOADING, + table: HttpStatus.LOADING, + }) + ); + + let test: QAMetricsAllData; - // TODO: make try/catch block shortly try { - // send request - const response = await QaMetricsApi.getQAMetricsData(); - - // separate to code and body - const code = response.status; - const body = await response.json(); - - // check, everything is ok - if (code === 209) { - dispatch(setQaMetricsStatus("data", HttpStatus.PREVIEW)); - dispatch(setStatusTrainModelQAMetrics(false)); - dispatch(addToast(body.warning.detail || body.warning.message, ToastStyle.Error)); - return; - } - - // save data to store - dispatch(setQAMetricsAllData(body)); - dispatch(setQaMetricsStatus("data", HttpStatus.FINISHED)); + test = await QaMetricsApi.getQAMetricsData(); } catch (e) { - dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); - dispatch(setQaMetricsStatus("data", HttpStatus.FAILED)); + dispatch( + setQaMetricsStatuses({ + data: HttpStatus.FAILED, + table: HttpStatus.FAILED, + }) + ); + + return; } + + dispatch(setQAMetricsAllData(test)); + + dispatch( + setQaMetricsStatuses({ + data: HttpStatus.FINISHED, + table: HttpStatus.FINISHED, + }) + ); }; }; export const updateQAMetricsTable = (limit: number, offset: number) => { return async (dispatch: any) => { - dispatch(setQaMetricsStatus("table", HttpStatus.RELOADING)); - - // TODO: make try/catch block shortly - try { - const response = await QaMetricsApi.getQAMetricsPredictionsTable(limit, offset); - - const code = response.status; - const body = await response.json(); + dispatch( + setQaMetricsStatuses({ + table: HttpStatus.RELOADING, + }) + ); - // check, everything is ok - if (code === 209) { - dispatch(setQaMetricsStatus("table", HttpStatus.PREVIEW)); - dispatch(addToast(body.warning.detail || body.warning.message, ToastStyle.Error)); - return; - } + let response: ObjectWithUnknownFields[]; - // save data to store - dispatch(setQAMetricsTable(body)); - dispatch(setQaMetricsStatus("table", HttpStatus.FINISHED)); + try { + response = await QaMetricsApi.getQAMetricsPredictionsTable(limit, offset); } catch (e) { - dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); - dispatch(setQaMetricsStatus("table", HttpStatus.FAILED)); + dispatch( + setQaMetricsStatuses({ + table: HttpStatus.FAILED, + }) + ); + + return; } + + dispatch(setQAMetricsTable(response)); + dispatch( + setQaMetricsStatuses({ + table: HttpStatus.FINISHED, + }) + ); }; }; diff --git a/frontend/src/app/common/store/qa-metrics/types.ts b/frontend/src/app/common/store/qa-metrics/types.ts index 8885d25..2ca98aa 100644 --- a/frontend/src/app/common/store/qa-metrics/types.ts +++ b/frontend/src/app/common/store/qa-metrics/types.ts @@ -1,19 +1,12 @@ -import { HttpStatus } from "app/common/types/http.types"; +import { ChartData, ChartsList } from "app/common/components/charts/types"; +import { HttpStatus, ObjectWithUnknownFields } from "app/common/types/http.types"; +import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { Terms } from "app/modules/significant-terms/store/types"; -export type QAMetricsStorePart = "filters" | "data" | "table"; - -export interface QAMetricsStore { - isModelTrained: boolean; - statuses: { - [key in QAMetricsStorePart]: HttpStatus; - }; - records_count: QAMetricsRecordsCount; - predictions_table: QAMetricsData[]; - prediction_table_rows_count: number; - areas_of_testing_chart: QAMetricsData; - priority_chart: QAMetricsData; - ttr_chart: QAMetricsData; - resolution_chart: QAMetricsResolutionChartData; +export interface QAMetricsStatuses { + filter: HttpStatus; + data: HttpStatus; + table: HttpStatus; } export interface QAMetricsRecordsCount { @@ -21,10 +14,23 @@ export interface QAMetricsRecordsCount { filtered: number; } -export interface QAMetricsData { - [key: string]: unknown; +export enum QAMetricsPrioritySortBy { + Value = 'value', + Name = 'name' +} + +export interface QAMetricsAllData { + predictions_table: ObjectWithUnknownFields[]; + prediction_table_rows_count: number; + areas_of_testing_chart: Terms; + priority_chart: ChartData; + ttr_chart: ChartData; + resolution_chart: ChartsList; } -export interface QAMetricsResolutionChartData { - [key: string]: QAMetricsData; +export interface QAMetricsStore extends QAMetricsAllData { + filter: FilterFieldBase[]; + statuses: QAMetricsStatuses; + records_count: QAMetricsRecordsCount; } + diff --git a/frontend/src/app/common/store/settings/actions.ts b/frontend/src/app/common/store/settings/actions.ts index 445318a..8569f72 100644 --- a/frontend/src/app/common/store/settings/actions.ts +++ b/frontend/src/app/common/store/settings/actions.ts @@ -2,18 +2,19 @@ import { SettingsActionTypes, SettingsSections, SettingsDataUnion, + FilterData, + PredictionTableData, + SettingsStatuses, } from "app/common/store/settings/types"; -import { HttpStatus } from "app/common/types/http.types"; export const activateSettings = () => ({ type: SettingsActionTypes.activateSettings, } as const); -export const setSettingsStatus = (section: SettingsSections, status: HttpStatus) => +export const setSettingsStatus = (statuses: Partial) => ({ - status, - section, + statuses, type: SettingsActionTypes.setSettingsStatus, } as const); @@ -24,6 +25,21 @@ export const uploadData = (section: SettingsSections, settings: SettingsDataUnio type: SettingsActionTypes.uploadData, } as const); +export const setATFiltersDefaultData = (filtersData: FilterData[]) => ({ + filtersData, + type: SettingsActionTypes.setATFiltersDefaultData +} as const) + +export const setQAMetricsFiltersDefaultData = (filtersData: FilterData[]) => ({ + filtersData, + type: SettingsActionTypes.setQAMetricsFiltersDefaultData +} as const) + +export const setPredictionsDefaultData = (predictionData: PredictionTableData[]) => ({ + predictionData, + type: SettingsActionTypes.setPredictionsDefaultData +} as const) + export const setCollectingDataStatus = (isCollectionFinished: boolean) => ({ isCollectionFinished, diff --git a/frontend/src/app/common/store/settings/reducers.ts b/frontend/src/app/common/store/settings/reducers.ts index 7400c5f..cf4f730 100644 --- a/frontend/src/app/common/store/settings/reducers.ts +++ b/frontend/src/app/common/store/settings/reducers.ts @@ -1,15 +1,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { SettingsStore, SettingsActionTypes, SettingsData } from "app/common/store/settings/types"; +import { SettingsStore, SettingsActionTypes, SettingsData, SettingsStatuses } from "app/common/store/settings/types"; import { HttpStatus } from "app/common/types/http.types"; import { InferValueTypes } from "app/common/store/utils"; import { copyData } from "app/common/functions/helper"; import { combineReducers } from "redux"; -import settingsTrainingReducer from "app/modules/settings/fields/settings_training/store/reducer"; +import settingsTrainingReducer from "app/modules/settings/parts/training/store/reducer"; import * as actions from "app/common/store/settings/actions"; const initialState: SettingsStore = { isOpen: false, - isCollectingFinished: false, status: { filters: HttpStatus.PREVIEW, qa_metrics: HttpStatus.PREVIEW, @@ -52,25 +51,38 @@ export const settingsReducer = ( action: actionsUserTypes ): SettingsStore => { const defaultSettings: SettingsData = { ...state.defaultSettings }; - const status = { ...state.status }; + const status: SettingsStatuses = { ...state.status }; switch (action.type) { case SettingsActionTypes.activateSettings: return { ...state, isOpen: !state.isOpen }; case SettingsActionTypes.setSettingsStatus: - status[action.section] = action.status; - return { ...state, status }; + return { + ...state, + status: { + ...status, + ...action.statuses + } + }; case SettingsActionTypes.uploadData: defaultSettings[action.section] = copyData(action.settings); return { ...state, defaultSettings }; - case SettingsActionTypes.setCollectingDataStatus: - return { ...state, isCollectingFinished: action.isCollectionFinished }; - case SettingsActionTypes.clearSettings: return { ...initialState }; + case SettingsActionTypes.setATFiltersDefaultData: + defaultSettings.filters.filter_settings = copyData(action.filtersData); + return { ...state, defaultSettings }; + + case SettingsActionTypes.setQAMetricsFiltersDefaultData: + defaultSettings.qa_metrics.filter_settings = copyData(action.filtersData); + return { ...state, defaultSettings }; + + case SettingsActionTypes.setPredictionsDefaultData: + defaultSettings.predictions_table.predictions_table_settings = copyData(action.predictionData); + return { ...state, defaultSettings }; default: return { ...state }; } diff --git a/frontend/src/app/common/store/settings/thunks.ts b/frontend/src/app/common/store/settings/thunks.ts index 9b4fd3a..cfd4723 100644 --- a/frontend/src/app/common/store/settings/thunks.ts +++ b/frontend/src/app/common/store/settings/thunks.ts @@ -1,41 +1,67 @@ -import { uploadData, setSettingsStatus, clearSettings } from "app/common/store/settings/actions"; -import { SettingsSections, SettingsDataUnion } from "app/common/store/settings/types" +import { uploadData, setSettingsStatus, clearSettings, setATFiltersDefaultData, setQAMetricsFiltersDefaultData, setPredictionsDefaultData } from "app/common/store/settings/actions"; +import { SettingsSections, PredictionTableData, FilterData } from "app/common/store/settings/types" import { SettingsApi } from "app/common/api/settings.api"; import { HttpError, HttpStatus } from 'app/common/types/http.types'; import { addToast } from "app/modules/toasts-overlay/store/actions"; import { ToastStyle } from "app/modules/toasts-overlay/store/types"; -import { clearSettingsTrainingData } from "app/modules/settings/fields/settings_training/store/actions"; +import { clearSettingsTrainingData } from "app/modules/settings/parts/training/store/actions"; -export const uploadSettingsData = (section: SettingsSections) => { +export const uploadSettingsATFilterData = () => { return async (dispatch: any) => { - dispatch(setSettingsStatus(section, HttpStatus.RELOADING)) + dispatch(setSettingsStatus({ [SettingsSections.filters]: HttpStatus.RELOADING })) let res; try { - res = await SettingsApi.getSettingsData(section); + res = await SettingsApi.getSettingsATFiltersData(); } catch (e) { - dispatch(setSettingsStatus(section, HttpStatus.FAILED)); - dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); + dispatch(riseSettingsError(SettingsSections.filters, (e as HttpError).detail || e.message)); return; } + dispatch(uploadData(SettingsSections.filters, res)); + dispatch(setSettingsStatus({ [SettingsSections.filters]: HttpStatus.FINISHED })); + } +} + +export const uploadSettingsQAMetricsFilterData = () => { + return async (dispatch: any) => { + dispatch(setSettingsStatus({ [SettingsSections.qaFilters]: HttpStatus.RELOADING })) + let res; - if (res.warning) { - dispatch(addToast(res.warning.detail, ToastStyle.Warning)); - dispatch(setSettingsStatus(section, HttpStatus.FAILED)); + try { + res = await SettingsApi.getSettingsQAMetricsFiltersData(); + } + catch (e) { + dispatch(riseSettingsError(SettingsSections.qaFilters, (e as HttpError).detail || e.message)); return; } + dispatch(uploadData(SettingsSections.qaFilters, res)); + dispatch(setSettingsStatus({ [SettingsSections.qaFilters]: HttpStatus.FINISHED })); + } +} - dispatch(uploadData(section, res)); - dispatch(setSettingsStatus(section, HttpStatus.FINISHED)); +export const uploadSettingsPredictionsData = () => { + return async (dispatch: any) => { + dispatch(setSettingsStatus({ [SettingsSections.predictions]: HttpStatus.RELOADING })) + let res; + try { + res = await SettingsApi.getSettingsPredictionsData(); + } + catch (e) { + dispatch(riseSettingsError(SettingsSections.predictions, (e as HttpError).detail || e.message)); + return; + } + dispatch(uploadData(SettingsSections.predictions, res)); + dispatch(setSettingsStatus({ [SettingsSections.predictions]: HttpStatus.FINISHED })); } } -export const sendSettingsData = (section: SettingsSections, data: SettingsDataUnion | any) => { +export const sendSettingsATFiltersData = (data: FilterData[]) => { return async (dispatch: any) => { try { - await SettingsApi.sendSettingsData(section, data); + await SettingsApi.sendSettingsATFiltersData(data); + dispatch(setATFiltersDefaultData(data)); } catch (e) { dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)) @@ -43,9 +69,41 @@ export const sendSettingsData = (section: SettingsSections, data: SettingsDataUn } } +export const sendSettingsQAMetricsFiltersData = (data: FilterData[]) => { + return async (dispatch: any) => { + try { + await SettingsApi.sendSettingsQAMetricsFiltersData(data); + dispatch(setQAMetricsFiltersDefaultData(data)); + } + catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)) + } + } +} + +export const sendSettingsPredictionsData = (data: PredictionTableData[]) => { + return async (dispatch: any) => { + try { + await SettingsApi.sendSettingsPredictionsData(data); + dispatch(setPredictionsDefaultData(data)) + + } + catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)) + } + } +} + +const riseSettingsError = (section: SettingsSections, errorTitle: string, toastStyle: ToastStyle = ToastStyle.Error) => { + return (dispatch: any) => { + dispatch(addToast(errorTitle, toastStyle)); + dispatch(setSettingsStatus({ [section]: HttpStatus.FAILED })); + } +} + export const clearSettingsData = () => { return (dispatch: any) => { dispatch(clearSettings()); dispatch(clearSettingsTrainingData()); } -} +} \ No newline at end of file diff --git a/frontend/src/app/common/store/settings/types.ts b/frontend/src/app/common/store/settings/types.ts index e9b3be9..0c40811 100644 --- a/frontend/src/app/common/store/settings/types.ts +++ b/frontend/src/app/common/store/settings/types.ts @@ -9,19 +9,26 @@ export enum SettingsActionTypes { sendData = "SEND_DATA", setCollectingDataStatus = "SET_COLLECTING_DATA_STATUS", clearSettings = "CLEAR_SETTINGS", + + setATFiltersDefaultData = "SET_AT_FILTERS_DEFAULT_DATA", + setQAMetricsFiltersDefaultData = "SET_QA_METRICS_FILTERS_DEFAULT_DATA", + setPredictionsDefaultData = "SET_PREDICTIONS_DEFAULT_DATA", } export enum SettingsSections { filters = "filters", qaFilters = "qa_metrics", predictions = "predictions_table", - training = "training", +} + +export type SettingsStatuses = { + [key in SettingsSections]: HttpStatus } // Type for Filter section: Analysis&Training and QAMetrics export type FilterData = { name: string; - filtration_type: string; + type: string; }; export type FilterType = { @@ -69,9 +76,8 @@ export interface SettingsData { } export interface SettingsStore { - isOpen: boolean; - isCollectingFinished: boolean; - status: { [key: string]: HttpStatus }; + isOpen: boolean; + status: SettingsStatuses; defaultSettings: SettingsData; } diff --git a/frontend/src/app/common/store/traininig/actions.ts b/frontend/src/app/common/store/traininig/actions.ts new file mode 100644 index 0000000..fcd7d72 --- /dev/null +++ b/frontend/src/app/common/store/traininig/actions.ts @@ -0,0 +1,7 @@ +import { HttpStatus } from "app/common/types/http.types"; + +export const setTrainingStatus = (status: HttpStatus) => + ({ + type: "SET_TRAINING_STATUS", + status, + } as const); diff --git a/frontend/src/app/common/store/traininig/reducers.ts b/frontend/src/app/common/store/traininig/reducers.ts new file mode 100644 index 0000000..f82b4df --- /dev/null +++ b/frontend/src/app/common/store/traininig/reducers.ts @@ -0,0 +1,26 @@ +import { TrainingStore } from "app/common/store/traininig/types"; +import { InferValueTypes } from "app/common/store/utils"; +import { HttpStatus } from "app/common/types/http.types"; +import * as actions from "./actions"; + +const initialState: TrainingStore = { + status: HttpStatus.LOADING, +}; + +type actionsTypes = ReturnType>; + +export const trainingReducers = ( + state: TrainingStore = initialState, + action: actionsTypes +): TrainingStore => { + switch (action.type) { + case "SET_TRAINING_STATUS": + return { + ...state, + status: action.status, + }; + + default: + return { ...state }; + } +}; diff --git a/frontend/src/app/common/store/traininig/thunks.ts b/frontend/src/app/common/store/traininig/thunks.ts new file mode 100644 index 0000000..daf60e9 --- /dev/null +++ b/frontend/src/app/common/store/traininig/thunks.ts @@ -0,0 +1,25 @@ +import { AnalysisAndTrainingApi } from "app/common/api/analysis-and-training.api"; +import { markModelNotTrained, searchTrainedModel } from "app/common/store/common/thunks"; +import { setTrainingStatus } from "app/common/store/traininig/actions"; +import { HttpError, HttpStatus } from "app/common/types/http.types"; +import { addToast } from "app/modules/toasts-overlay/store/actions"; +import { ToastStyle } from "app/modules/toasts-overlay/store/types"; + +export const startTrainModel = () => { + return async (dispatch: any) => { + dispatch(markModelNotTrained()); + dispatch(setTrainingStatus(HttpStatus.RELOADING)); + dispatch(addToast("Start training", ToastStyle.Info)); + + try { + await AnalysisAndTrainingApi.trainModel(); + } catch (e) { + dispatch(addToast((e as HttpError).detail || e.message, ToastStyle.Error)); + dispatch(setTrainingStatus(HttpStatus.FAILED)); + return; + } + + dispatch(searchTrainedModel()); + dispatch(setTrainingStatus(HttpStatus.FINISHED)); + }; +}; diff --git a/frontend/src/app/common/store/traininig/types.ts b/frontend/src/app/common/store/traininig/types.ts new file mode 100644 index 0000000..d069ce9 --- /dev/null +++ b/frontend/src/app/common/store/traininig/types.ts @@ -0,0 +1,5 @@ +import { HttpStatus } from "app/common/types/http.types"; + +export interface TrainingStore { + status: HttpStatus +} diff --git a/frontend/src/app/common/types/analysis-and-training.types.ts b/frontend/src/app/common/types/analysis-and-training.types.ts index 1f431e0..2719556 100644 --- a/frontend/src/app/common/types/analysis-and-training.types.ts +++ b/frontend/src/app/common/types/analysis-and-training.types.ts @@ -1,23 +1,6 @@ -import { HttpStatus } from 'app/common/types/http.types'; import { FilterFieldBase } from 'app/modules/filters/field/field-type'; import { Terms } from 'app/modules/significant-terms/store/types'; -export interface AnalysisAndTrainingStore { - status: HttpStatus; - frequentlyTermsList: string[]; - statistic: AnalysisAndTrainingStatistic | null; - defectSubmission: AnalysisAndTrainingDefectSubmission; - isCollectingFinished: boolean; -} - -export type AnalysisAndTrainingDefectSubmission = { - created_line: LineData; - resolved_line: LineData; - created_total_count: number; - resolved_total_count: number; - period: string; -} | null; - type LineData = { [key: string]: number; }; @@ -37,12 +20,15 @@ export interface ApplyFilterBody { } export interface SignificantTermsData { - metrics: string[], - chosen_metric: string | null, - terms: Terms + metrics: string[]; + chosen_metric: string | null; + terms: Terms; } export interface DefectSubmissionData { - data: AnalysisAndTrainingDefectSubmission | undefined, - activePeriod: string | undefined, + created_line: LineData; + resolved_line: LineData; + created_total_count: number; + resolved_total_count: number; + period: string; } diff --git a/frontend/src/app/common/types/http.types.ts b/frontend/src/app/common/types/http.types.ts index d81f24c..39fe720 100644 --- a/frontend/src/app/common/types/http.types.ts +++ b/frontend/src/app/common/types/http.types.ts @@ -7,6 +7,10 @@ export enum HttpStatus { FAILED = "failed", } +export interface ObjectWithUnknownFields { + [key: string]: T +} + export interface HttpException { detail: string; code: number; @@ -26,20 +30,16 @@ export class HttpError extends Error implements HttpException { } } -interface FieldError { +export interface HTTPFieldValidationError { name: string; errors: string[]; } -export class HttpValidationError extends HttpError { - fields: FieldError[]; - detailArr: string[] = []; +export class HTTPValidationError { - constructor(props: HttpException, fields: FieldError[]) { - super(props); + fields: HTTPFieldValidationError[] + constructor(fields: HTTPFieldValidationError[]) { this.fields = fields; - this.detail = this.fields[0].errors[0]; - this.fields.forEach(({ errors }) => this.detailArr.push(...errors)); } } diff --git a/frontend/src/app/common/types/store.types.ts b/frontend/src/app/common/types/store.types.ts index ddef3ec..a450958 100644 --- a/frontend/src/app/common/types/store.types.ts +++ b/frontend/src/app/common/types/store.types.ts @@ -1,10 +1,13 @@ +import { AnalysisAndTrainingStore } from "app/common/store/analysis-and-training/types"; import { CommonStore } from "app/common/store/common/types"; import { QAMetricsStore } from "app/common/store/qa-metrics/types"; import { AuthStore } from "app/common/store/auth/types"; import { SettingsStore } from "app/common/store/settings/types"; +import { TrainingStore } from "app/common/store/traininig/types"; import { VirtualAssistantStore } from "app/common/store/virtual-assistant/types"; import { ToastStore } from "app/modules/toasts-overlay/store/types"; -import { SettingTrainingStore } from "app/modules/settings/fields/settings_training/store/types"; +import { SettingTrainingStore } from "app/modules/settings/parts/training/store/types"; +import { DescriptionAssessmentStore } from "app/common/store/description-assessment/types"; export interface RootStore { toasts: ToastStore; @@ -16,4 +19,7 @@ export interface RootStore { virtualAssistant: VirtualAssistantStore; qaMetricsPage: QAMetricsStore; common: CommonStore; + analysisAndTraining: AnalysisAndTrainingStore; + descriptionAssessment: DescriptionAssessmentStore, + training: TrainingStore } diff --git a/frontend/src/app/common/types/user.types.ts b/frontend/src/app/common/types/user.types.ts index 2e3c0e8..a939ffa 100644 --- a/frontend/src/app/common/types/user.types.ts +++ b/frontend/src/app/common/types/user.types.ts @@ -17,7 +17,6 @@ export interface UserSignIn { * Model of user data for registration */ export interface UserSignUp { - team: number | null; email: string; name: string; password: string; @@ -34,11 +33,3 @@ export interface User { role: UserRole; // role name, not id token: string; } - -/** - * Main team model - */ -export interface Team { - id: number; - name: string; -} diff --git a/frontend/src/app/modules/defect-submission/defect-submission.scss b/frontend/src/app/modules/defect-submission/defect-submission.scss index 0cb299e..bc7aeac 100644 --- a/frontend/src/app/modules/defect-submission/defect-submission.scss +++ b/frontend/src/app/modules/defect-submission/defect-submission.scss @@ -100,7 +100,7 @@ margin-top: 18px; display: flex; - justify-content: flex-end; + justify-content: center; } &__period { @@ -114,7 +114,9 @@ border-radius: 1000px; - margin-left: 10px; + &:not(:first-of-type) { + margin-left: 10px; + } transition-property: background-color, color; transition-duration: 0.1s; @@ -130,6 +132,26 @@ color: $deepDarkBlue; } } + + &__y-axis-bar{ + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + margin-right: 30px; + + @media screen and (max-width: 1600px){ + margin-right: 25px; + } + @media screen and (max-width: 1280px){ + margin-right: 20px; + } + + span{ + transform: none; + color: $gray; + } + } } .line-chart { @@ -171,6 +193,7 @@ &__axis-text { font-family: Quicksand; font-style: normal; + fill: $gray; font-weight: 500; font-size: 13px; transform: translateY(20px); @@ -178,6 +201,12 @@ } @media screen and (max-width: 1280px) { + .defect-submission__period { + font-size: 14px !important; + } + .line-chart__axis-text{ + font-size: 12px !important; + } .defect-submission-legend { &__point { width: 11px !important; diff --git a/frontend/src/app/modules/defect-submission/defect-submission.tsx b/frontend/src/app/modules/defect-submission/defect-submission.tsx index 55f065d..e6c0558 100644 --- a/frontend/src/app/modules/defect-submission/defect-submission.tsx +++ b/frontend/src/app/modules/defect-submission/defect-submission.tsx @@ -1,9 +1,12 @@ -import { AnalysisAndTrainingDefectSubmission } from "app/common/types/analysis-and-training.types"; +import { DefectSubmissionData } from "app/common/types/analysis-and-training.types"; import React from "react"; import cn from "classnames"; import "./defect-submission.scss"; import Tooltip from "app/common/components/tooltip/tooltip"; +import { connect, ConnectedProps } from "react-redux"; +import { setCardStatuses } from "app/common/store/analysis-and-training/actions"; +import { HttpStatus } from "app/common/types/http.types"; const TimeFilters = { Day: "Day", @@ -15,8 +18,7 @@ const TimeFilters = { }; interface Props { - defectSubmission: AnalysisAndTrainingDefectSubmission | undefined; - activeTimeFilter: string; + defectSubmission: DefectSubmissionData; onChangePeriod: (period: string) => void; } @@ -29,7 +31,7 @@ type LineCoordinateTemplate = { type GraphLinePoint = { data: string; createdValue: number; resolvedValue: number }; -class DefectSubmission extends React.Component { +class DefectSubmission extends React.Component { maxValue = 0; minValue = 0; segmentWidth = 120; @@ -40,7 +42,6 @@ class DefectSubmission extends React.Component { graphData: GraphLinePoint[] = []; graphTooltipRef: React.RefObject = React.createRef(); - graphPointerRef: React.RefObject = React.createRef(); graphRef: React.RefObject = React.createRef(); pointerCoordinates: number[] = [0, 0]; @@ -48,7 +49,10 @@ class DefectSubmission extends React.Component { colorSchema: string[] = ["#E61A1A", "#33CC99"]; - constructor(props: Props) { + timerId: NodeJS.Timeout | null = null; + resizeInterval = 500; + + constructor(props: PropsFromRedux) { super(props); if (!this.props.defectSubmission) return; @@ -57,7 +61,7 @@ class DefectSubmission extends React.Component { ([data, value]: [string, number]) => ({ data, createdValue: value, - resolvedValue: this.props.defectSubmission!.resolved_line[data], + resolvedValue: this.props.defectSubmission.resolved_line[data], }) ); @@ -92,8 +96,8 @@ class DefectSubmission extends React.Component { if (svgElem && svgElem[0] && svgWrapper && svgWrapper[0]) { const wrapperWidth = (svgWrapper.item(0) as HTMLDivElement).offsetWidth; - if (wrapperWidth > this.segmentWidth * this.graphData.length) - this.segmentWidth = (0.975 * wrapperWidth) / (this.graphData.length - 1 || 2); + const segmentWidth = (0.95 * wrapperWidth) / (this.graphData.length - 1 || 2); + this.segmentWidth = segmentWidth <= 120 ? 120 : segmentWidth; this.scrollableGraphWidth = (this.graphData.length - 1 @@ -103,18 +107,46 @@ class DefectSubmission extends React.Component { this.potentialGraphHeight = svgElem.item(0).height.baseVal.value; this.forceUpdate(); } + + window.addEventListener("resize", this.resizeGraph); + }; + + componentWillUnmount = () => { + window.removeEventListener("resize", this.resizeGraph); + }; + + resizeGraph = () => { + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + + this.timerId = setTimeout(() => { + const { setCardStatuses } = this.props; + setCardStatuses({ defectSubmission: HttpStatus.RELOADING }); + setCardStatuses({ defectSubmission: HttpStatus.FINISHED }); + }, this.resizeInterval); }; displayPoint = (coords: number[], colorIndex: number) => { if ( - !this.graphPointerRef.current || + !this.graphTooltipRef.current || (coords[0] === this.pointerCoordinates[0] && coords[1] === this.pointerCoordinates[1]) ) return; + const point = this.graphTooltipRef.current.getElementsByClassName( + "defect-submission__graph-pointer" + )[0] as HTMLDivElement; + this.pointerCoordinates = [...coords]; - this.graphPointerRef.current.style.cssText = `left: ${coords[0]}px; top: ${coords[1]}px; background: ${this.colorSchema[colorIndex]}; display: flex`; + Object.assign(point.style, { + left: `${coords[0]}px`, + top: `${coords[1]}px`, + background: this.colorSchema[colorIndex], + display: "flex", + }); }; displayTooltip = (coords: number[], dataIndex: number, lineIndex: number) => { @@ -126,12 +158,17 @@ class DefectSubmission extends React.Component { this.tooltipCoordinates = [...coords]; + const tooltip = this.graphTooltipRef.current.getElementsByClassName( + "tooltip-wrapper" + )[0] as HTMLDivElement; + const dataField = lineIndex === 0 ? "createdValue" : "resolvedValue"; - this.graphTooltipRef.current.children[0].innerHTML = `${this.graphData[dataIndex][dataField]}`; - this.graphTooltipRef.current.style.cssText = `left: ${coords[0] - 15}px; top: ${ - coords[1] - 5 - }px;`; + tooltip.children[0].innerHTML = `${this.graphData[dataIndex][dataField]}`; + Object.assign(tooltip.style, { + left: `${coords[0]}px`, + top: `${coords[1]}px`, + }); }; euclideanDistance = (x1: number, y1: number, x2: number, y2: number) => @@ -379,25 +416,21 @@ class DefectSubmission extends React.Component { const yAxis = []; - for (let i = 0; i <= beautifulSteps; i += 1) + for (let i = beautifulSteps; i >= 0; i -= 1) yAxis.push( - + {beautifulTick * i} - + ); return yAxis; }; render() { - const { activeTimeFilter } = this.props; - const graphHeight = Number(this.graphRef.current?.offsetHeight) - 5 || "calc( 100% - 25px )"; + const { period } = this.props.defectSubmission; + const graphHeight = navigator.userAgent.includes("Chrome") + ? "calc( 100% - 25px )" + : Number(this.graphRef.current?.offsetHeight) - 5 || "calc( 100% - 30px )"; return (
@@ -408,7 +441,7 @@ class DefectSubmission extends React.Component { "defect-submission-legend__point", "defect-submission-legend__point_created" )} - /> + />

Created issues - {this.props.defectSubmission?.created_total_count}

@@ -419,7 +452,7 @@ class DefectSubmission extends React.Component { "defect-submission-legend__point", "defect-submission-legend__point_resolved" )} - /> + />

Resolved issues - {this.props.defectSubmission?.resolved_total_count}

@@ -427,24 +460,25 @@ class DefectSubmission extends React.Component {
-
- - {this.renderYAxis()} - +
+ {this.renderYAxis()}
- -
- - +
+ +
+ +
{ > {this.graphData.length === 1 ? this.renderGraphPoint() - : this.graphData.map((item, index) => this.renderGraphLine(index))} + : this.graphData.map((_, index) => this.renderGraphLine(index))}
@@ -465,7 +499,7 @@ class DefectSubmission extends React.Component { onClick={this.setFilter(val)} className={cn( "defect-submission__period", - activeTimeFilter === val && "defect-submission__period_active" + period === val && "defect-submission__period_active" )} > {val} @@ -477,4 +511,12 @@ class DefectSubmission extends React.Component { } } -export default DefectSubmission; +const mapDispatchToProps = { + setCardStatuses, +}; + +const connector = connect(undefined, mapDispatchToProps); + +type PropsFromRedux = ConnectedProps & Props; + +export default connector(DefectSubmission); diff --git a/frontend/src/app/modules/filters/field/date-range/date-range.scss b/frontend/src/app/modules/filters/field/date-range/date-range.scss index 3e6ba6b..3d93150 100644 --- a/frontend/src/app/modules/filters/field/date-range/date-range.scss +++ b/frontend/src/app/modules/filters/field/date-range/date-range.scss @@ -180,6 +180,7 @@ // for checked range &:focus, + &:active, &--active, &--active:enabled:hover, &--active:enabled:focus, diff --git a/frontend/src/app/modules/filters/field/date-range/date-range.tsx b/frontend/src/app/modules/filters/field/date-range/date-range.tsx index c397a83..d97de2a 100644 --- a/frontend/src/app/modules/filters/field/date-range/date-range.tsx +++ b/frontend/src/app/modules/filters/field/date-range/date-range.tsx @@ -52,6 +52,17 @@ class DateRange extends React.Component { this.field = new FilterField(this.props.field, this.props.updateFunction); } + getInitialDateValue = (calendarField: ShowCalendarField) => { + const field = + calendarField === ShowCalendarField.startDate + ? this.field.current_value[0] + : this.field.current_value[1]; + + if (field instanceof Date) return field; + if (field) return String(field); + return undefined; + }; + editZeroDate = (date: string | Date | undefined): string => { if (!date) return ""; if (date instanceof Date) @@ -114,17 +125,33 @@ class DateRange extends React.Component { }; onBlurInput = (dateField: ShowCalendarField) => () => { - const inputtedDate = this.editZeroDate(this.state[dateField]); - const dateFieldMoment = moment(inputtedDate, ["DD.MM.YY", "DD.MM.YYYY"], true); + const invalidDate = "Invalid date"; + const dateFormats = ["DD.MM.YY", "DD.MM.YYYY"]; + + const inputtedDate = this.editZeroDate( + this.state[dateField] || this.getInitialDateValue(dateField) + ); + const dateFieldMoment = moment(inputtedDate, dateFormats, true); + + const validatedDate = + !this.state[dateField] || + (dateFieldMoment.isValid() && + dateFieldMoment.isBetween(this.props.minDateValue, this.props.maxDateValue, "day", "[]")) + ? inputtedDate + : invalidDate; + + let startDate = dateField === ShowCalendarField.startDate ? validatedDate : this.state.start; + let endDate = dateField === ShowCalendarField.endDate ? validatedDate : this.state.end; + + if (startDate && endDate && moment(endDate, dateFormats) < moment(startDate, dateFormats)) { + [startDate, endDate] = [endDate, startDate]; + } + this.setState( - (state, props) => ({ - [dateField]: - !state[dateField] || - (dateFieldMoment.isValid() && - dateFieldMoment.isBetween(props.minDateValue, props.maxDateValue, "day", "[]")) - ? inputtedDate - : "Invalid date", - }), + { + start: startDate, + end: endDate, + }, this.applyChanges ); }; @@ -168,13 +195,15 @@ class DateRange extends React.Component { } onChange={this.handleDirectChanges(ShowCalendarField.startDate)} onBlur={this.onBlurInput(ShowCalendarField.startDate)} - value={this.state.start === undefined ? startDate : this.state.start} + value={this.state.start || startDate} /> - + +
); } diff --git a/frontend/src/app/modules/filters/field/field-type.ts b/frontend/src/app/modules/filters/field/field-type.ts index 8299078..965d6d7 100644 --- a/frontend/src/app/modules/filters/field/field-type.ts +++ b/frontend/src/app/modules/filters/field/field-type.ts @@ -18,7 +18,7 @@ export type ValueUnion = export interface FilterFieldBase { name: string; - filtration_type: FiltrationType; + type: FiltrationType; exact_match: boolean; current_value: ValueUnion; values?: FilterFieldDropdownValue; diff --git a/frontend/src/app/modules/filters/field/field.helper-function.ts b/frontend/src/app/modules/filters/field/field.helper-function.ts index 65155ef..309c75b 100644 --- a/frontend/src/app/modules/filters/field/field.helper-function.ts +++ b/frontend/src/app/modules/filters/field/field.helper-function.ts @@ -6,8 +6,8 @@ import { ValueUnion, } from "app/modules/filters/field/field-type"; -export const getFieldEmptyValue = (filtration_type: FiltrationType): ValueUnion => { - switch (filtration_type) { +export const getFieldEmptyValue = (type: FiltrationType): ValueUnion => { + switch (type) { case FiltrationType.String: return ""; @@ -21,10 +21,10 @@ export const getFieldEmptyValue = (filtration_type: FiltrationType): ValueUnion }; export const setFieldValue = ( - filtration_type: FiltrationType, + type: FiltrationType, newValue: ValueUnion ): ValueUnion => { - switch (filtration_type) { + switch (type) { case FiltrationType.String: return newValue; @@ -45,8 +45,8 @@ export const setFieldValue = ( } }; -export const checkFieldIsFilled = (filtration_type: FiltrationType, value: ValueUnion): boolean => { - switch (filtration_type) { +export const checkFieldIsFilled = (type: FiltrationType, value: ValueUnion): boolean => { + switch (type) { case FiltrationType.String: return !!value; diff --git a/frontend/src/app/modules/filters/field/field.tsx b/frontend/src/app/modules/filters/field/field.tsx index a47c6b8..f4ff42c 100644 --- a/frontend/src/app/modules/filters/field/field.tsx +++ b/frontend/src/app/modules/filters/field/field.tsx @@ -20,11 +20,11 @@ interface Props { class Field extends React.Component { render() { const { field } = this.props; - const { filtration_type } = this.props.field; + const { type } = this.props.field; return (
- {filtration_type === FiltrationType.String && ( + {type === FiltrationType.String && ( { /> )} - {filtration_type === FiltrationType.Dropdown && ( + {type === FiltrationType.Dropdown && ( { /> )} - {filtration_type === FiltrationType.Numeric && ( + {type === FiltrationType.Numeric && ( { /> )} - {filtration_type === FiltrationType.Date && ( + {type === FiltrationType.Date && ( caseInsensitiveStringCompare(a, b)); + this.values = field.values.map(val => String(val)); } this.updateFunction = updateFunction; @@ -50,21 +49,21 @@ export class FilterField { }; updateValue = (newValue: ValueUnion) => { - this.current_value = setFieldValue(this.filtration_type, newValue); + this.current_value = setFieldValue(this.type, newValue); }; resetValue = () => { this.toggleExactMatch(false); - this.updateValue(getFieldEmptyValue(this.filtration_type)); + this.updateValue(getFieldEmptyValue(this.type)); this.applyField(); }; applyField = () => { const field: FilterFieldBase = { name: this.name, - filtration_type: this.filtration_type, + type: this.type, exact_match: this.exact_match, - current_value: setFieldValue(this.filtration_type, this.current_value), + current_value: setFieldValue(this.type, this.current_value), }; if (this.values) field.values = this.values; this.updateFunction(field); diff --git a/frontend/src/app/modules/filters/filters.class.ts b/frontend/src/app/modules/filters/filters.class.ts index 91cf966..34ce326 100644 --- a/frontend/src/app/modules/filters/filters.class.ts +++ b/frontend/src/app/modules/filters/filters.class.ts @@ -12,8 +12,8 @@ export class FiltersClass { this.fields = this.fields.map((field: FilterFieldBase) => ({ ...field, current_value: setFieldValue( - field.filtration_type, - getFieldEmptyValue(field.filtration_type) + field.type, + getFieldEmptyValue(field.type) ), })); }; @@ -30,12 +30,12 @@ export class FiltersClass { setFilter(filter: FilterFieldBase[]): FilterFieldBase[] { return filter.map((field) => { - if (field.filtration_type === FiltrationType.String && field.current_value instanceof Array) { + if (field.type === FiltrationType.String && field.current_value instanceof Array) { field.current_value = field.current_value.length ? (field.current_value as [string])[0] : ""; } - if (field.filtration_type === FiltrationType.Dropdown) field.values!.sort(); + if (field.type === FiltrationType.Dropdown) field.values!.sort(); return field; }); } diff --git a/frontend/src/app/modules/filters/filters.scss b/frontend/src/app/modules/filters/filters.scss index fcb8b3c..a37e4f9 100644 --- a/frontend/src/app/modules/filters/filters.scss +++ b/frontend/src/app/modules/filters/filters.scss @@ -48,4 +48,8 @@ justify-content: space-between; align-items: center; } + + &__apply{ + width: 154px; + } } diff --git a/frontend/src/app/modules/filters/filters.tsx b/frontend/src/app/modules/filters/filters.tsx index 98272fe..eeec6ea 100644 --- a/frontend/src/app/modules/filters/filters.tsx +++ b/frontend/src/app/modules/filters/filters.tsx @@ -2,6 +2,7 @@ import Button, { ButtonStyled } from "app/common/components/button/button"; import { IconType } from "app/common/components/icon/icon"; import Field from "app/modules/filters/field/field"; import { FilterFieldBase } from "app/modules/filters/field/field-type"; +import { checkFieldIsFilled } from "app/modules/filters/field/field.helper-function"; import { FiltersClass } from "app/modules/filters/filters.class"; import cn from "classnames"; import React from "react"; @@ -39,7 +40,11 @@ export class Filters extends React.Component { }; apply = () => { - this.props.applyFilters(this.filters.fields); + this.props.applyFilters( + this.filters.fields.filter((field) => + checkFieldIsFilled(field.type, field.current_value) + ) + ); }; checIsFiltersDefault = (filters: FilterFieldBase[]) => diff --git a/frontend/src/app/modules/main-statistic/main-statistic.scss b/frontend/src/app/modules/main-statistic/main-statistic.scss index cf624a0..299a361 100644 --- a/frontend/src/app/modules/main-statistic/main-statistic.scss +++ b/frontend/src/app/modules/main-statistic/main-statistic.scss @@ -29,5 +29,6 @@ line-height: 17px; color: #656565; text-align: center; + white-space: nowrap; } } diff --git a/frontend/src/app/modules/predict-text/predict-text.tsx b/frontend/src/app/modules/predict-text/predict-text.tsx index 1dacb1a..d8509aa 100644 --- a/frontend/src/app/modules/predict-text/predict-text.tsx +++ b/frontend/src/app/modules/predict-text/predict-text.tsx @@ -34,6 +34,7 @@ interface Props { onChangePredictOption: (predictOption: PredictMetric) => void; onPredict: (text: string) => void; onClearAll: () => void; + text: string; } class PredictText extends React.Component { @@ -47,6 +48,12 @@ class PredictText extends React.Component { layoutArr: [], }; + componentDidMount() { + this.setState({ + text: this.props.text + }); + } + onChangeText = (text: string) => { this.setState({ text, @@ -79,7 +86,9 @@ class PredictText extends React.Component { }; changeDropdownValue = (option: PredictMetricsName) => (value: any) => { - const layoutArr: PredictMetricsName[] = [...this.state.layoutArr].filter((item) => item !== option); + const layoutArr: PredictMetricsName[] = [...this.state.layoutArr].filter( + (item) => item !== option + ); layoutArr.push(option); this.props.onChangePredictOption({ diff --git a/frontend/src/app/modules/predictions-table/predictions-table.scss b/frontend/src/app/modules/predictions-table/predictions-table.scss index 4ebd38a..0207ee2 100644 --- a/frontend/src/app/modules/predictions-table/predictions-table.scss +++ b/frontend/src/app/modules/predictions-table/predictions-table.scss @@ -21,26 +21,19 @@ &__table { width: 100%; - &_with-head { - margin-top: 20px; - - tbody { - visibility: collapse; - } - } - - &_with-body { - thead { - visibility: collapse; - } - } - th { + position: sticky; + top: 0; + z-index: 5; + background: $white; + transform: translateY(-5px); + font-size: 16px; line-height: 20px; color: $darkGray; + text-align: start; - padding: 0 18px; + padding: 20px 18px 5px 18px; &:first-child { padding-left: 0; @@ -52,9 +45,6 @@ } } - tr { - } - td { max-width: 400px; text-align: center; @@ -64,6 +54,12 @@ line-height: 20px; font-weight: 500; color: $darkGray; + text-align: start; + + @media screen and (max-width: 1280px) { + font-size: 13px; + font-weight: 400; + } &:first-child { padding-left: 0; @@ -75,33 +71,6 @@ } } } - - .color { - &_green { - color: $green; - } - &_dark-blue { - color: $darkBlue; - } - &_orange { - color: $orange; - } - &_violet-dark { - color: $violetDark; - } - &_cold { - color: $cold; - } - &_yellow-strong { - color: $yellowStrong; - } - &_orange { - color: $orange; - } - &_light-red { - color: $lightRed; - } - } } .predictions-table-pagination { @@ -112,23 +81,29 @@ display: flex; align-items: center; - &:not(:last-child) { - margin-right: 40px; - } - } - &__select{ - -moz-appearance: none; - -webkit-appearance: none; - width: 50px; - - &-icon{ - transform: translateX(-100%); - pointer-events: none; - } - } - &__button { - margin: 0 5px; - background-color: transparent; + &:not(:last-child) { + margin-right: 40px; + } + + .dropdown-element { + width: 60px; + + &__select { + background: transparent; + padding: 0; + border: none; + } + + &-wrapper { + transform: translateX(-10px); + max-width: min-content; + } + } + } + + &__button { + margin: 0 5px; + background-color: transparent; &__icon { color: $darkGray; diff --git a/frontend/src/app/modules/predictions-table/predictions-table.tsx b/frontend/src/app/modules/predictions-table/predictions-table.tsx index 5ea918b..303edd8 100644 --- a/frontend/src/app/modules/predictions-table/predictions-table.tsx +++ b/frontend/src/app/modules/predictions-table/predictions-table.tsx @@ -1,13 +1,12 @@ -import Icon, { IconSize, IconType } from 'app/common/components/icon/icon'; -import { QAMetricsData } from 'app/common/store/qa-metrics/types'; -import React from 'react'; - +import Icon, { IconType } from "app/common/components/icon/icon"; +import { ObjectWithUnknownFields } from "app/common/types/http.types"; +import React from "react"; import "./predictions-table.scss"; -import cn from "classnames"; -import Tooltip from "app/common/components/tooltip/tooltip"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import TableCell from "app/modules/predictions-table/table-cell/table-cell"; interface IProps { - tableData: QAMetricsData[]; + tableData: ObjectWithUnknownFields[]; totalCount: number; onChangePage: (pageIndex: number, limit: number) => void; } @@ -23,13 +22,18 @@ class PredictionsTable extends React.Component { currentPage: 1, }; - onChangeLimit = (e: React.ChangeEvent) => { - const newLimit = Number(e.target.value); + onChangeLimit = (limit: string) => { + const newLimit = Number(limit); const oldLimit = this.state.limit; let newCurrentPage = Math.ceil((this.state.currentPage * oldLimit) / newLimit); - if ((newCurrentPage - 1) * newLimit > this.props.totalCount) + if (oldLimit > newLimit) { + newCurrentPage = ((this.state.currentPage - 1) * oldLimit) / newLimit + 1; + } + + if ((newCurrentPage - 1) * newLimit > this.props.totalCount) { newCurrentPage = Math.ceil(this.props.totalCount / newLimit); + } this.setState({ limit: newLimit, @@ -39,37 +43,9 @@ class PredictionsTable extends React.Component { }; setPage = (newPage: number) => () => { - this.setState({ - currentPage: newPage, - }); - this.props.onChangePage(newPage, this.state.limit); - }; - - // TODO: refactor method to function - determineColor = (value: string): string => { - switch (value) { - case "Won’t Fixed": - return "green"; - case "Not Won’t Fixed": - return "dark-blue"; - - case "Rejected": - return "orange"; - case "Not Rejected": - return "violet-dark"; - - case "0–30 days": - return "cold"; - case "31–90 days": - return "yellow-strong"; - case "91–180 days": - return "orange"; - case "> 180 days": - return "light-red"; - - default: - return "default"; - } + const currentPage = newPage < 1 ? 1 : newPage; + this.setState({ currentPage }); + this.props.onChangePage(currentPage, this.state.limit); }; render() { @@ -81,30 +57,9 @@ class PredictionsTable extends React.Component {
{this.renderTableHeader()} - {/* for top fixed table header */} - - - - {columnsNames.map((columnName, index) => ( - - ))} - - - - - {tableData.map((item, index) => ( - - {columnsNames.map((columnName, index) => ( - - ))} - - ))} - -
{columnName}
{String(item[columnName])}
- {/* data table */}
- +
{columnsNames.map((columnName, index) => ( @@ -116,29 +71,9 @@ class PredictionsTable extends React.Component { {tableData.map((item, index) => ( - {columnsNames.map((columnName, index) => { - const str = String(item[columnName]); - - const charActualWidth = 10; // Actual average width of symbols with font-size 16px - const isTooltipDisplayed = - str.replace(/\W/g, "").length * charActualWidth > 400; // if sum width of all symbols larger than td max-width than display tooltip - - return ( - - ); + {columnsNames.map((columnName) => { + const message = String(item[columnName]); + return ; })} ))} @@ -165,17 +100,12 @@ class PredictionsTable extends React.Component {
Show by - - - + writable={false} + value={this.state.limit.toString()} + />
diff --git a/frontend/src/app/modules/predictions-table/table-cell/table-cell.scss b/frontend/src/app/modules/predictions-table/table-cell/table-cell.scss new file mode 100644 index 0000000..3aaca79 --- /dev/null +++ b/frontend/src/app/modules/predictions-table/table-cell/table-cell.scss @@ -0,0 +1,36 @@ +@import "src/app/styles/colors"; + +.table-cell { + transition: height 0.2s ease-in-out; + + &__title { + word-break: break-all; + } +} + +.color { + &_green { + color: $green; + } + &_dark-blue { + color: $darkBlue; + } + &_orange { + color: $orange; + } + &_violet-dark { + color: $violetDark; + } + &_cold { + color: $cold; + } + &_yellow-strong { + color: $yellowStrong; + } + &_orange { + color: $orange; + } + &_light-red { + color: $lightRed; + } +} diff --git a/frontend/src/app/modules/predictions-table/table-cell/table-cell.tsx b/frontend/src/app/modules/predictions-table/table-cell/table-cell.tsx new file mode 100644 index 0000000..76de5bd --- /dev/null +++ b/frontend/src/app/modules/predictions-table/table-cell/table-cell.tsx @@ -0,0 +1,81 @@ +import React, { CSSProperties, useEffect, useRef, useState } from "react"; +import cn from "classnames"; +import "./table-cell.scss"; +import Tooltip from "app/common/components/tooltip/tooltip"; + +// TODO: refactor method to function +function determineTableCellColor(value: string): string { + switch (value) { + case "Won’t Fixed": + return "green"; + case "Not Won’t Fixed": + return "dark-blue"; + + case "Rejected": + return "orange"; + case "Not Rejected": + return "violet-dark"; + + case "0–30 days": + return "cold"; + case "31–90 days": + return "yellow-strong"; + case "91–180 days": + return "orange"; + case "> 180 days": + return "light-red"; + + default: + return "default"; + } +} + +function getShortVersion(message: string, length: number) { + return `${message.slice(0, length)}...`; +} + +export default function TableCell({ message }: { message: string }) { + const [isContentHidden, setIsContentHidden] = useState(false); + const [isTooltipDisplayed, setIsTooltipDisplayed] = useState(false); + const tdRef = useRef(null); + const style = useRef({}); + const shortenMessageLength = useRef(undefined); + useEffect(() => { + if (tdRef.current) { + style.current = { + width: tdRef.current.clientWidth, + }; + + if (tdRef.current.clientWidth < message.replace(/\W/g, "").length * charActualWidth) { + shortenMessageLength.current = tdRef.current.clientWidth / charActualWidth; + setIsTooltipDisplayed(true); + } + } + }, [message]); + + const charActualWidth = 10; // Actual average width of symbols with font-size 16px + const tdMessage = + isTooltipDisplayed && !isContentHidden && shortenMessageLength.current + ? getShortVersion(message, shortenMessageLength.current) + : message; + const tooltipMessage = isContentHidden + ? "Click again to collapse" + : `${tdMessage}\n\nClick to show whole description`; + const textColor = determineTableCellColor(message); + + return ( +
+ ); +} diff --git a/frontend/src/app/modules/settings/elements/input-training-element/input-training-element.scss b/frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.scss similarity index 88% rename from frontend/src/app/modules/settings/elements/input-training-element/input-training-element.scss rename to frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.scss index bfc55c3..c4e1298 100644 --- a/frontend/src/app/modules/settings/elements/input-training-element/input-training-element.scss +++ b/frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.scss @@ -45,8 +45,9 @@ &_odd { background-color: $veryLightGray; } - - &_simple, &_disabled { + + &_simple, + &_disabled { border-top-left-radius: 5px; border-top-right-radius: 5px; } @@ -89,26 +90,25 @@ } } + &-blocks-wrapper { + display: flex; + position: absolute; + top: 0; + width: calc(100% - 125px); + height: 100%; + } + &-value-block { - width: calc(50% - 35px); + max-width: 50%; + flex-grow: 0; + display: inline-flex; + flex-direction: row; + align-items: center; background-color: $grayDisabled; - display: inline-block; - position: relative; border-radius: 4px; - height: 30px; margin: 5px; - - &__wrapper { - width: 100%; - height: 100%; - position: absolute; - top: 50%; - transform: translateY(-50%); - padding: 5px; - display: inline-flex; - align-items: center; - } + padding: 5px; &__number { color: $darkBlue; diff --git a/frontend/src/app/modules/settings/elements/input-training-element/input-training-element.tsx b/frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.tsx similarity index 82% rename from frontend/src/app/modules/settings/elements/input-training-element/input-training-element.tsx rename to frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.tsx index 64a8570..77c5d3b 100644 --- a/frontend/src/app/modules/settings/elements/input-training-element/input-training-element.tsx +++ b/frontend/src/app/modules/settings/elements/input-entity-element/input-entity-element.tsx @@ -7,9 +7,9 @@ import { } from "app/modules/settings/elements/elements-types"; import SelectWindow from "app/common/components/native-components/select-window/select-window"; import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; -import "app/modules/settings/elements/input-training-element/input-training-element.scss"; +import "./input-entity-element.scss"; -interface InputTrainingElementProps { +interface Props { type: FilterElementType; onChange: (value: string) => void; onClear: (index: number) => void; @@ -18,15 +18,12 @@ interface InputTrainingElementProps { dropDownValues: string[]; } -interface InputTrainingElementState { +interface State { isSelectWindowOpen: boolean; isSelectedListOpen: boolean; } -export default class InputTrainingElement extends Component< - InputTrainingElementProps, - InputTrainingElementState -> { +export default class InputEntityElement extends Component { // eslint-disable-next-line react/static-property-placement static defaultProps = { type: FilterElementType.simple, @@ -38,7 +35,7 @@ export default class InputTrainingElement extends Component< inputTrainingElementRef: React.RefObject = React.createRef(); allowedEditing = false; - constructor(props: InputTrainingElementProps) { + constructor(props: Props) { super(props); this.state = { @@ -47,8 +44,9 @@ export default class InputTrainingElement extends Component< }; } - onFocusTrainingElement = (): void => { + onFocusTrainingElement = (e: any): void => { if (!this.allowedEditing) return; + if (this.timerID) clearTimeout(this.timerID); this.setState({ isSelectWindowOpen: true }); }; @@ -87,25 +85,26 @@ export default class InputTrainingElement extends Component< deleteAllValueBlocks = (): void => { const { onClearAll } = this.props; + if (this.state.isSelectedListOpen) { + this.onBlurTrainingElement(); + } onClearAll(); }; renderValueBlocks = (content: string, index: number): React.ReactNode => { return (
-
-

{index + 1}

-

{content}

- {this.allowedEditing && ( - - )} -
+

{index + 1}

+

{content}

+ {this.allowedEditing && ( + + )}
); }; @@ -154,21 +153,23 @@ export default class InputTrainingElement extends Component< `input-training-element-block-container_${type}` )} > - {values.length ? ( - [...values].splice(0, 2).map((item, index) => this.renderValueBlocks(item, index)) - ) : ( + {!values.length && (

Entities Name

)} +
+ {!!values.length && + [...values].splice(0, 2).map((item, index) => this.renderValueBlocks(item, index))} +
+ {values.length > 2 && this.allowedEditing && ( )} diff --git a/frontend/src/app/modules/settings/fields/settings_filter/setings_filter.tsx b/frontend/src/app/modules/settings/fields/settings_filter/setings_filter.tsx deleted file mode 100644 index 8611402..0000000 --- a/frontend/src/app/modules/settings/fields/settings_filter/setings_filter.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React, { Component } from "react"; -import cn from "classnames"; -import "app/modules/settings/fields/settings_filter/settings_filter.scss"; -import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; -import { - FilterElementType, - FilterDropdownType, -} from "app/modules/settings/elements/elements-types"; -import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; -import Button, { ButtonStyled } from "app/common/components/button/button"; -import { connect, ConnectedProps } from "react-redux"; -import { RootStore } from "app/common/types/store.types"; -import { SettingsSections } from "app/common/store/settings/types"; -import { sendSettingsData } from "app/common/store/settings/thunks"; -import { caseInsensitiveStringCompare } from "app/common/functions/helper"; - -interface SettingsFilterData { - [key: string]: string; - name: string; - filtration_type: string; -} - -interface SettingsFilterState { - [key: string]: - | boolean - | Array - | SettingsFilterData - | Array - | string[]; - names: string[]; - settings: Array; - dataInput: SettingsFilterData; - dataEdit: SettingsFilterData; - status: Array; - isSettingsDefault: boolean; -} - -interface SettingsFilterProps { - section: SettingsSections.filters | SettingsSections.qaFilters; -} - -class SettingsFilter extends Component { - constructor(props: Props) { - super(props); - this.state = this.getDefaultStateObject(); - } - - setFieldData = (keyField: "dataInput" | "dataEdit", valField: keyof SettingsFilterData) => ( - value: string - ) => { - this.setState((prevState) => { - const data: SettingsFilterData = prevState[keyField]; - data[valField] = value; - return { - [keyField]: data, - }; - }); - }; - - clearFieldData = ( - keyField: "dataInput" | "dataEdit", - valField?: keyof SettingsFilterData - ) => () => { - this.setState((prevState) => { - let data: SettingsFilterData = prevState[keyField]; - - if (valField) data[valField] = ""; - else data = { name: "", filtration_type: "" }; - - return { - [keyField]: data, - }; - }); - }; - - addTableRow = () => { - const { settings, status, dataInput } = this.state; - - settings.push({ ...dataInput }); - status.push(this.getTableRowParity(status.length)); - - this.setState({ - settings, - status, - }); - this.clearFieldData("dataInput")(); - this.detectIsSettingsDefault(); - }; - - changeTableRowHoverStatus = (index: number) => ({ type }: any) => { - const { status } = this.state; - - if (status[index] === FilterElementType.edited) return; - - switch (type) { - case "mouseenter": - status[index] = FilterElementType.hovered; - break; - default: - status[index] = this.getTableRowParity(index); - break; - } - - this.setState({ status }); - }; - - editTableRowData = (index: number) => () => { - const { status, settings } = this.state; - const dataEdit = { ...settings[index] }; - - status[index] = FilterElementType.edited; - - this.setState({ status, dataEdit }); - }; - - acceptTableRowEditing = (index: number) => () => { - const { settings, status, dataEdit } = this.state; - - settings[index] = { ...dataEdit }; - status[index] = this.getTableRowParity(index); - - this.setState({ - settings, - status, - }); - this.detectIsSettingsDefault(); - }; - - deleteTableRow = (index: number) => () => { - const { settings, status } = this.state; - settings.splice(index, 1); - status.pop(); - this.setState({ - settings, - status, - }); - this.detectIsSettingsDefault(); - }; - - setDefaultSettings = () => { - this.setState(this.getDefaultStateObject()); - }; - - saveSettings = () => { - const { sendSettingsData, section } = this.props; - const { settings } = this.state; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - sendSettingsData(section, settings); - this.detectIsSettingsDefault(true); - }; - - getDefaultStateObject = (): SettingsFilterState => { - const { defaultSettings, section } = this.props; - - return { - names: [...defaultSettings[section].names], - settings: this.sortTableRows(defaultSettings[section].filter_settings), - status: this.getDefaultTableRowsStatuses(defaultSettings[section].filter_settings.length), - dataInput: { - name: "", - filtration_type: "", - }, - dataEdit: { - name: "", - filtration_type: "", - }, - isSettingsDefault: true, - }; - }; - - getDefaultTableRowsStatuses = (length: number) => - [...new Array(length)].map((_, index) => this.getTableRowParity(index)); - - sortTableRows = (arr: Array) => - [...arr].sort((firstItem: SettingsFilterData, secondItem: SettingsFilterData) => - caseInsensitiveStringCompare(firstItem.name, secondItem.name) - ); - - getTableRowParity = (numb: number) => - numb % 2 === 1 ? FilterElementType.odd : FilterElementType.even; - - detectIsSettingsDefault = (isSettingsDefault = false) => this.setState({ isSettingsDefault }); - - isPositionAcceptButtonValid = (field: "dataInput" | "dataEdit") => { - // eslint-disable-next-line react/destructuring-assignment - const data: SettingsFilterData = this.state[field]; - return !(data.name && data.filtration_type); - }; - - render() { - const { settings, dataInput, names, status, dataEdit, isSettingsDefault } = this.state; - const excludeNames = settings.map((item) => item.name); - - return ( -
-

Filter

- -
-
-

Name

- -
- -
-

Filtration Type

-
- - - -
-
-
- -
- {settings.map(({ name, filtration_type }, index) => ( -
-
- exName !== name)} - /> -
- -
-
- - - {status[index] === FilterElementType.edited && ( - - )} -
-
- - {status[index] === FilterElementType.hovered && ( -
- - - -
- )} -
- ))} -
- -
-
-
- ); - } -} - -const mapStateToProps = ({ settings }: RootStore) => ({ - defaultSettings: settings.settingsStore.defaultSettings, -}); - -const mapDispatchToProps = { - sendSettingsData, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; -type Props = PropsFromRedux & SettingsFilterProps; - -export default connector(SettingsFilter); diff --git a/frontend/src/app/modules/settings/fields/settings_filter/settings_filter.scss b/frontend/src/app/modules/settings/fields/settings_filter/settings_filter.scss deleted file mode 100644 index 6cb42b2..0000000 --- a/frontend/src/app/modules/settings/fields/settings_filter/settings_filter.scss +++ /dev/null @@ -1,117 +0,0 @@ -@import "../../../../styles/colors"; - -.settings-filter { - font-weight: 500; - font-size: 14px; - line-height: 17px; - color: $darkGray; - - &__button { - &:disabled { - cursor: not-allowed; - } - } - &__title { - font-weight: 500; - font-size: 20px; - line-height: 22px; - color: $deepDarkBlue; - } - - &-name { - width: 40%; - - &_tabled { - border-right: 1px solid $darkBlue; - } - } - - &-type { - width: 60%; - &__dropdown-wrapper { - display: flex; - } - &__accept-button { - background-color: $seaBlue; - width: 60px; - color: white; - display: flex; - justify-content: center; - align-items: center; - } - } - - &-header { - display: flex; - margin-top: 15px; - - &__title { - margin-bottom: 5px; - } - - &__dropdown-wrapper { - display: flex; - align-items: center; - } - - &__add-position { - display: inline-block; - padding: 6px; - margin-left: 15px; - border: 1.5px solid $gray; - background-color: transparent; - border-radius: 100%; - opacity: 0.5; - cursor: pointer; - transform: rotate(45deg); - transition: opacity 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out; - - &:hover:not([disabled]) { - opacity: 1; - color: $seaBlue; - border-color: $seaBlue; - } - } - } - - &-main { - margin-top: 15px; - box-sizing: border-box; - - &__section-edit-wrapper { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - display: flex; - } - - &__edit-button, - &__delete-button { - margin-right: 10px; - background-color: transparent; - color: white; - opacity: 0.5; - transition: opacity 0.2s ease-out; - - &:hover { - opacity: 1; - - &:disabled { - cursor: not-allowed; - } - } - } - - &__section { - display: flex; - position: relative; - } - } - - &-footer { - display: flex; - justify-content: space-between; - margin-top: 30px; - } -} diff --git a/frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.tsx b/frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.tsx deleted file mode 100644 index 3001fa4..0000000 --- a/frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import React, { Component } from "react"; -import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; -import Button, { ButtonStyled } from "app/common/components/button/button"; -import InputPredictionsElement from "app/modules/settings/elements/input-predictions-element/input-predictions-element"; -import cn from "classnames"; -import { connect, ConnectedProps } from "react-redux"; -import { RootStore } from "app/common/types/store.types"; -import { sendSettingsData } from "app/common/store/settings/thunks"; -import { PredictionTableData, SettingsSections } from "app/common/store/settings/types"; -import "app/modules/settings/fields/settings_predictions/settings_predictions.scss"; -import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; - -interface SettingsPredictionsState { - names: string[]; - predictions: PredictionTableData[]; - dataInput: string; - isSettingsDefault: boolean; -} - -class SettingsPredictions extends Component { - constructor(props: SettingsPredictionsProps) { - super(props); - this.state = this.getDefaultStateObject(); - } - - detectIsSettingsDefault = (isSettingsDefault = false) => this.setState({ isSettingsDefault }); - - setInputData = (dataInput: string) => { - this.setState({ dataInput }); - }; - - clearInputData = () => { - this.setInputData(""); - }; - - addPredictionBlock = () => { - const { predictions, dataInput } = this.state; - - predictions.push({ - name: dataInput, - is_default: false, - position: predictions.length + 1, - settings: 0, - }); - - this.setState({ predictions }); - this.clearInputData(); - this.detectIsSettingsDefault(); - }; - - deletePredictionBlock = (index: number) => () => { - const { predictions } = this.state; - - predictions.splice(index, 1); - - this.setState({ predictions }); - this.fixPredictionsBlocksOrder(); - this.detectIsSettingsDefault(); - }; - - fixPredictionsBlocksOrder = () => { - let { predictions } = this.state; - predictions = predictions.map((item, index) => ({ ...item, position: index + 1 })); - this.setState({ predictions }); - }; - - changeValueBlocksOrder = (indexOfDraggedVal: number, indexOfNewPosition: number) => { - const { predictions } = this.state; - const val = predictions.splice(indexOfDraggedVal, 1)[0]; - - predictions.splice(indexOfNewPosition, 0, val); - - this.setState({ predictions }); - this.fixPredictionsBlocksOrder(); - this.detectIsSettingsDefault(); - }; - - saveSettings = () => { - const { sendSettingsData } = this.props; - const { predictions } = this.state; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - sendSettingsData(SettingsSections.predictions, predictions); - - this.detectIsSettingsDefault(true); - }; - - setDefaultSettings = () => { - this.setState(this.getDefaultStateObject()); - }; - - getDefaultStateObject = (): SettingsPredictionsState => { - const { predictions } = this.props; - - return { - names: [...predictions.field_names], - predictions: [...predictions.predictions_table_settings], - dataInput: "", - isSettingsDefault: true, - }; - }; - - render() { - const { predictions, dataInput, names, isSettingsDefault } = this.state; - const excludeNames = predictions.map((item) => item.name); - - return ( -
-

Predictions

- -
-

Add Own Element

-
- - -
-
- -
- -
- -
-
-
- ); - } -} - -const mapStateToProps = ({ settings }: RootStore) => ({ - predictions: settings.settingsStore.defaultSettings.predictions_table, -}); - -const mapDispatchToProps = { - sendSettingsData, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; -type SettingsPredictionsProps = PropsFromRedux & unknown; - -export default connector(SettingsPredictions); diff --git a/frontend/src/app/modules/settings/fields/settings_training/settings_training.scss b/frontend/src/app/modules/settings/fields/settings_training/settings_training.scss deleted file mode 100644 index af22a6f..0000000 --- a/frontend/src/app/modules/settings/fields/settings_training/settings_training.scss +++ /dev/null @@ -1,204 +0,0 @@ -@import "../../../../styles/colors"; - -.settings-training { - color: $darkGray; - - &__button { - &:disabled { - cursor: not-allowed; - } - } - - &__title, - &__subtitle { - font-weight: 500; - font-size: 20px; - line-height: 22px; - color: $deepDarkBlue; - } - - &__subtitle { - margin: 20px 0; - font-size: 18px; - color: $darkBlue; - } - - &__add-position { - display: inline-block; - padding: 6px; - margin-left: 15px; - border: 1.5px solid $gray; - background-color: transparent; - border-radius: 100%; - opacity: 0.5; - cursor: pointer; - transform: rotate(45deg); - transition: opacity 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out; - - &:hover:not([disabled]) { - opacity: 1; - color: $seaBlue; - border-color: $seaBlue; - } - } - - &-source { - display: flex; - align-items: center; - margin-top: 15px; - - &__title { - margin-right: 20px; - } - - &__arrow-left, - &__inscription, - &__check { - color: $orange; - margin: 0 20px; - } - - &__arrow-left { - animation: arrow_move 1s ease-in-out both alternate infinite; - } - - &__check { - color: $seaBlue; - } - - &__inscription { - margin-left: 0; - - &_success { - color: $seaBlue; - } - } - } - - &-table { - &-main { - margin-top: 15px; - - &__edit-button, - &__delete-button { - background-color: $seaBlue; - color: rgba(255, 255, 255, 0.5); - transition: color 0.2s ease-out; - - &:hover { - color: rgba(255, 255, 255, 1); - } - } - - &-section { - position: relative; - display: flex; - - &_select { - display: flex; - } - - &__edit-wrapper { - position: absolute; - right: 0; - display: flex; - top: 50%; - transform: translateY(-50%); - } - } - } - - &__accept-button { - background-color: $seaBlue; - width: 60px; - color: white; - display: flex; - justify-content: center; - align-items: center; - } - - &-input { - width: 40%; - &_tabled { - border-right: 1px solid $darkBlue; - } - } - - &-select { - width: 60%; - - &__select-wrapper { - display: flex; - align-items: center; - } - } - } - - &-table-header { - margin-top: 20px; - - &__wrapper { - margin-top: 15px; - display: flex; - } - - &__title { - font-size: 16px; - } - &__field-title { - font-size: 14px; - margin-bottom: 5px; - } - } - - &-bug-resolution { - margin-top: 25px; - - &__title { - font-weight: 500; - font-size: 18px; - color: $darkBlue; - } - - &-metric, - &-value { - display: flex; - align-items: center; - } - - &-wrapper { - display: flex; - margin-top: 15px; - } - - &-metric { - &__title { - margin-right: 20px; - } - } - - &-value { - &__title { - margin-right: 20px; - margin-left: 30px; - } - } - } - - &-footer { - margin-top: 30px; - padding-bottom: 10px; - display: flex; - align-items: center; - justify-content: space-between; - } -} - -@keyframes arrow_move { - from { - transform: translateX(10px); - } - to { - transform: translateX(-10px); - } -} diff --git a/frontend/src/app/modules/settings/fields/settings_training/settings_training.tsx b/frontend/src/app/modules/settings/fields/settings_training/settings_training.tsx deleted file mode 100644 index 83ea3b8..0000000 --- a/frontend/src/app/modules/settings/fields/settings_training/settings_training.tsx +++ /dev/null @@ -1,559 +0,0 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import React, { Component } from "react"; -import "app/modules/settings/fields/settings_training/settings_training.scss"; -import InputElement from "app/modules/settings/elements/input-element/input-element"; -import InputTrainingElement from "app/modules/settings/elements/input-training-element/input-training-element"; -import { FilterElementType } from "app/modules/settings/elements/elements-types"; -import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; -import Button, { ButtonStyled } from "app/common/components/button/button"; -import { MarkUpEntities, SettingsSections } from "app/common/store/settings/types"; -import { connect, ConnectedProps } from "react-redux"; -import { RootStore } from "app/common/types/store.types"; -import { sendSettingsData } from "app/common/store/settings/thunks"; -import { - sendSettingsTrainingData, - uploadSettingsTrainingSubfieldData, -} from "app/modules/settings/fields/settings_training/store/thunks"; -import cn from "classnames"; -import { - MarkUpEntitiesData, - BugResolutionData, - SourceFieldData, - MarkUpEntitiesElement, - TrainingSubSection, - BugResolutionElement, -} from "app/modules/settings/fields/settings_training/store/types"; -import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; - -interface SettingsTrainingState { - [key: string]: any; - - source_field: SourceFieldData; - markup_entities: MarkUpEntitiesData; - bug_resolution: BugResolutionData; - markUpEntitiesInputData: MarkUpEntitiesElement; - markUpEntitiesEditData: MarkUpEntitiesElement; - status: FilterElementType[]; - isSettingsDefault: boolean; - isAllowedEntitiesEditing: boolean; -} - -class SettingsTraining extends Component { - constructor(props: Props) { - super(props); - this.state = this.getDefaultStateObject(); - } - - setMarkUpEntitiesData = ( - keyField: "markUpEntitiesInputData" | "markUpEntitiesEditData", - valField: keyof MarkUpEntities - ) => (value: string) => { - this.setState((prevState) => { - const data: MarkUpEntitiesElement = prevState[keyField]; - if (valField === "area_of_testing") data.area_of_testing = value; - else data.entities.push(value); - return { [keyField]: data }; - }); - }; - - clearMarkUpEntitiesInputData = ( - keyField: "markUpEntitiesInputData" | "markUpEntitiesEditData" - ) => () => this.setMarkUpEntitiesData(keyField, "area_of_testing")(""); - - clearMarkUpEntitiesBlockValueData = ( - keyField: "markUpEntitiesInputData" | "markUpEntitiesEditData" - ) => (index: number) => { - this.setState((prevState) => { - const data = prevState[keyField]; - data.entities.splice(index, 1); - return { [keyField]: data }; - }); - }; - - clearAllMarkUpEntitiesBlockValueData = ( - keyField: "markUpEntitiesInputData" | "markUpEntitiesEditData" - ) => () => { - this.setState((prevState) => { - const data = prevState[keyField]; - data.entities = []; - return { [keyField]: data }; - }); - }; - - setSourceFieldData = (name: string, isAllowedEntitiesEditing = false) => { - const { source_field } = this.state; - source_field.source_field = name; - this.setState({ source_field, isAllowedEntitiesEditing }); - this.detectIsSettingsDefault(); - }; - - setMarkUpSource = (name: string) => { - this.setSourceFieldData(name); - setTimeout(() => { - const { sendSettingsTrainingData, uploadSettingsTrainingSubfieldData } = this.props; - - sendSettingsTrainingData({ source_field: name }, TrainingSubSection.source_field).then( - (isAllowedEntitiesEditing) => { - if (isAllowedEntitiesEditing) { - this.setState({ isAllowedEntitiesEditing }); - uploadSettingsTrainingSubfieldData(TrainingSubSection.markup_entities).then( - (markup) => { - if (markup) { - this.setState((prevState) => { - const { markup_entities } = prevState; - markup_entities.entity_names = [...markup.entity_names]; - return { markup_entities }; - }); - } else this.setState({ isAllowedEntitiesEditing: false }); - } - ); - } - } - ); - }, 1000); - }; - - clearMarkUpSource = () => { - this.setSourceFieldData(""); - }; - - changeBugResolutionValue = (index: number) => (value: string) => { - const { bug_resolution } = this.state; - - bug_resolution.resolution_settings[index] = { - metric: bug_resolution.resolution_settings[index].metric, - value, - }; - - this.setState({ bug_resolution }); - this.detectIsSettingsDefault(); - }; - - clearBugResolutionValue = (index: number) => () => { - this.changeBugResolutionValue(index)(""); - }; - - changeTableRowHoverStatus = (index: number) => ({ type }: any) => { - const { status, isAllowedEntitiesEditing } = this.state; - if (status[index] === FilterElementType.edited || !isAllowedEntitiesEditing) return; - - switch (type) { - case "mouseenter": - status[index] = FilterElementType.hovered; - break; - default: - status[index] = this.getTableRowParity(index); - break; - } - - this.setState({ status }); - }; - - addTableRow = () => { - const { markup_entities, status } = this.state; - let { markUpEntitiesInputData } = this.state; - markup_entities.mark_up_entities.push({ ...markUpEntitiesInputData }); - markUpEntitiesInputData = { - area_of_testing: "", - entities: [], - }; - status.push(this.getTableRowParity(markup_entities.mark_up_entities.length + 1)); - - this.setState({ - markup_entities, - markUpEntitiesInputData, - status, - }); - this.detectIsSettingsDefault(); - }; - - editTableRowData = (index: number) => () => { - const { status, markUpEntitiesEditData, markup_entities } = this.state; - - status[index] = FilterElementType.edited; - markUpEntitiesEditData.area_of_testing = - markup_entities.mark_up_entities[index].area_of_testing; - markUpEntitiesEditData.entities = [...markup_entities.mark_up_entities[index].entities]; - - this.setState({ status, markUpEntitiesEditData }); - }; - - acceptTableRowEditing = (index: number) => () => { - const { status, markup_entities } = this.state; - let { markUpEntitiesEditData } = this.state; - - status[index] = this.getTableRowParity(index); - markup_entities.mark_up_entities[index] = markUpEntitiesEditData; - markUpEntitiesEditData = { - area_of_testing: "", - entities: [], - }; - - this.setState({ - status, - markup_entities, - markUpEntitiesEditData, - }); - this.detectIsSettingsDefault(); - }; - - deleteTableRow = (index: number) => () => { - const { markup_entities, status } = this.state; - - markup_entities.mark_up_entities.splice(index, 1); - status.pop(); - - this.setState({ - markup_entities, - status, - }); - this.detectIsSettingsDefault(); - }; - - saveSettings = () => { - const { markup_entities, bug_resolution } = this.state; - const { sendSettingsData } = this.props; - - const settings = { - mark_up_entities: [...markup_entities.mark_up_entities], - bug_resolution: [...bug_resolution.resolution_settings], - }; - - sendSettingsData(SettingsSections.training, settings); - this.detectIsSettingsDefault(true); - }; - - setDefaultSettings = () => { - const newState = this.getDefaultStateObject(); - this.setState({ ...newState }); - }; - - detectIsSettingsDefault = (isSettingsDefault = false) => this.setState({ isSettingsDefault }); - - getTableRowParity = (index: number) => - index % 2 === 1 ? FilterElementType.odd : FilterElementType.even; - - getDefaultStateObject = () => { - const { training } = this.props; - const { bug_resolution } = training; - - const resolution_settings = [ - { metric: "Resolution", value: "" }, - { metric: "Resolution", value: "" }, - ].map((item: BugResolutionElement, index: number) => - training.bug_resolution.resolution_settings[index] - ? { ...training.bug_resolution.resolution_settings[index] } - : item - ); - - bug_resolution.resolution_settings = resolution_settings; - - return { - source_field: { ...training.source_field }, - markup_entities: { ...training.markup_entities }, - bug_resolution, - markUpEntitiesInputData: { - area_of_testing: "", - entities: [], - }, - markUpEntitiesEditData: { - area_of_testing: "", - entities: [], - }, - status: training.markup_entities.mark_up_entities.map((_: any, index: number) => - this.getTableRowParity(index) - ), - isSettingsDefault: true, - isAllowedEntitiesEditing: !!training.source_field.source_field.length, - }; - }; - - render() { - const { - bug_resolution, - source_field, - isAllowedEntitiesEditing, - markUpEntitiesInputData, - status, - markup_entities, - markUpEntitiesEditData, - isSettingsDefault, - } = this.state; - - const bugResolutionExcludeValues = bug_resolution.resolution_settings - ? bug_resolution.resolution_settings.map((item) => item.value) - : []; - - return ( -
-

Training

- -

Areas of Testing

- -
-

Source Field

- - - {isAllowedEntitiesEditing ? ( - - ) : ( - - )} - - - {isAllowedEntitiesEditing ? ( - <>Source Field Saved - ) : ( - <>Set Source Field first to add Entities - )} - -
- -
-
-
-

Name

- -
- -
-

Entities

-
- - -
-
-
-
- -
- {markup_entities.mark_up_entities.map((item: MarkUpEntitiesElement, index: number) => ( -
-
- -
-
- - {status[index] === FilterElementType.edited && ( - - )} -
- - {status[index] === FilterElementType.hovered && ( -
- - - -
- )} -
- ))} -
- -
-

Bug Resolution

- {Array(2) - .fill("") - .map((_, index) => ( - // eslint-disable-next-line react/no-array-index-key -
-
-

Metric

- -
-
-

Value

- item !== bug_resolution.resolution_settings[index].value - )} - value={bug_resolution.resolution_settings[index].value} - onChange={this.changeBugResolutionValue(index)} - onClear={this.clearBugResolutionValue(index)} - /> -
-
- ))} -
- -
-
-
- ); - } -} - -const mapStateToProps = ({ settings }: RootStore) => ({ - training: settings.settingsTrainingStore, -}); - -const mapDispatchToProps = { - sendSettingsData, - sendSettingsTrainingData, - uploadSettingsTrainingSubfieldData, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; -type Props = PropsFromRedux & unknown; - -export default connector(SettingsTraining); diff --git a/frontend/src/app/modules/settings/fields/settings_training/store/actions.ts b/frontend/src/app/modules/settings/fields/settings_training/store/actions.ts deleted file mode 100644 index e04ed19..0000000 --- a/frontend/src/app/modules/settings/fields/settings_training/store/actions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { HttpStatus } from "app/common/types/http.types"; -import { - SettingsTrainingResponseType, - TrainingActionTypes, - TrainingSubSection, -} from "app/modules/settings/fields/settings_training/store/types"; - -export const setSettingsTrainingData = ( - data: SettingsTrainingResponseType, - section: TrainingSubSection -) => - ({ - data, - section, - type: TrainingActionTypes.setSettingsTrainingData, - } as const); - -export const setSettingsTrainingStatus = (status: HttpStatus, section: TrainingSubSection) => - ({ - section, - status, - type: TrainingActionTypes.setSettingsTrainingStatus, - } as const); - -export const clearSettingsTrainingData = () => - ({ type: TrainingActionTypes.clearSettingsTrainingData } as const); diff --git a/frontend/src/app/modules/settings/fields/settings_training/store/thunks.ts b/frontend/src/app/modules/settings/fields/settings_training/store/thunks.ts deleted file mode 100644 index f1be659..0000000 --- a/frontend/src/app/modules/settings/fields/settings_training/store/thunks.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable consistent-return */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - -// TODO: Declare static types for this thunk - -import { SettingsApi } from "app/common/api/settings.api"; -import { TrainingSubSection } from "app/modules/settings/fields/settings_training/store/types"; -import { SettingsSections } from "app/common/store/settings/types" -import { addToast } from "app/modules/toasts-overlay/store/actions"; -import { ToastStyle } from "app/modules/toasts-overlay/store/types"; -import { HttpStatus } from "app/common/types/http.types"; -import { - setSettingsTrainingData, - setSettingsTrainingStatus, -} from "app/modules/settings/fields/settings_training/store/actions"; - -export const uploadSettingsTrainingSubfieldData = (subfield: TrainingSubSection) => { - return async (dispatch: any) => { - let res; - try { - res = await SettingsApi.getSettingsData(SettingsSections.training, subfield); - } - catch (e) { - dispatch(addToast(e.message, ToastStyle.Error)); - dispatch(setSettingsTrainingStatus(HttpStatus.FAILED, subfield)); - return; - } - if (res.warning) { - dispatch(setSettingsTrainingStatus(HttpStatus.FAILED, subfield)); - return - } - dispatch(setSettingsTrainingData(res, subfield)); - dispatch(setSettingsTrainingStatus(HttpStatus.FINISHED, subfield)); - return res; - } -} - -export const sendSettingsTrainingData = (data: any, subfield: TrainingSubSection) => { - return async (dispatch: any) => { - try { - let res = await SettingsApi.sendSettingsData(SettingsSections.training, data, subfield); - return res.result === "success"; - } - catch (e) { - dispatch(addToast(e.message, ToastStyle.Error)); - } - } -} - -export const uploadSettingsTrainingData = () => { - return async (dispatch: any) => { - try { - Object.values(TrainingSubSection).forEach(item => { - dispatch(setSettingsTrainingStatus(HttpStatus.RELOADING, item)); - dispatch(uploadSettingsTrainingSubfieldData(item)); - }) - } - catch (e) { - dispatch(addToast(e.message, ToastStyle.Error)); - } - } -} diff --git a/frontend/src/app/modules/settings/parts/filter/components/form/form.scss b/frontend/src/app/modules/settings/parts/filter/components/form/form.scss new file mode 100644 index 0000000..e671bc5 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/form/form.scss @@ -0,0 +1,57 @@ +@import "../../../../../../styles/colors"; + +.settings-filter-form { + display: flex; + margin-top: 15px; + + &__title { + margin-bottom: 5px; + } + + &__dropdown-wrapper { + display: flex; + align-items: center; + } + + &__add-position { + display: inline-block; + padding: 6px; + margin-left: 15px; + border: 1.5px solid $gray; + background-color: transparent; + border-radius: 100%; + opacity: 0.5; + cursor: pointer; + transform: rotate(45deg); + transition: opacity 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out; + + &:hover:not([disabled]) { + opacity: 1; + color: $seaBlue; + border-color: $seaBlue; + } + } + + &__name { + width: 40%; + + &_tabled { + border-right: 1px solid $darkBlue; + } + } + + &__type { + width: 60%; + &__dropdown-wrapper { + display: flex; + } + &__accept-button { + background-color: $seaBlue; + width: 60px; + color: white; + display: flex; + justify-content: center; + align-items: center; + } + } +} diff --git a/frontend/src/app/modules/settings/parts/filter/components/form/form.tsx b/frontend/src/app/modules/settings/parts/filter/components/form/form.tsx new file mode 100644 index 0000000..9baf2e7 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/form/form.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; +import cn from "classnames"; +import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import { FilterDropdownType } from "app/modules/settings/elements/elements-types"; +import { SettingsFilterData } from "app/modules/settings/parts/filter/filter"; +import "./form.scss"; + +interface Props { + names: string[]; + excludeNames: string[]; + onAddTableRow: (rowData: SettingsFilterData) => void; +} + +export default function SettingsFilterForm(props: Props) { + // Form state + const [name, setName] = useState(""); + const [type, setFiltrationType] = useState(""); + + // Form method + const addTableRow = () => { + props.onAddTableRow({ name, type }); + setName(""); + setFiltrationType(""); + }; + + return ( +
+
+

Name

+ setName(value)} + onClear={() => setName("")} + style={{ width: "90%" }} + value={name} + dropDownValues={props.names} + excludeValues={props.excludeNames} + /> +
+ +
+

Filtration Type

+
+ setFiltrationType(value)} + onClear={() => setFiltrationType("")} + value={type} + dropDownValues={Object.values(FilterDropdownType)} + writable={false} + /> + + +
+
+
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.scss b/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.scss new file mode 100644 index 0000000..9ba241a --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.scss @@ -0,0 +1,60 @@ +@import "../../../../../../../styles/colors"; + +.settings-filter-table-row { + display: flex; + position: relative; + + &__name { + width: 40%; + } + + &__type { + width: 60%; + } + + &__tabled { + border-right: 1px solid $darkBlue; + } + + &__dropdown-wrapper { + display: flex; + } + + &__accept-button { + background-color: $seaBlue; + width: 60px; + color: white; + display: flex; + justify-content: center; + align-items: center; + + &:disabled { + cursor: not-allowed; + } + } + + &__section-edit-wrapper { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + } + + &__edit-button, + &__delete-button { + margin-right: 10px; + background-color: transparent; + color: white; + opacity: 0.5; + transition: opacity 0.2s ease-out; + + &:hover { + opacity: 1; + + &:disabled { + cursor: not-allowed; + } + } + } +} diff --git a/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.tsx b/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.tsx new file mode 100644 index 0000000..f78fe10 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/table/table-row/table-row.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from "react"; +import cn from "classnames"; +import Icon, { IconType, IconSize } from "app/common/components/icon/icon"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import { + FilterElementType, + FilterDropdownType, +} from "app/modules/settings/elements/elements-types"; +import { SettingsFilterData } from "app/modules/settings/parts/filter/filter"; +import "./table-row.scss"; + +interface Props { + status: FilterElementType; + chosenName: string; + chosenFiltrationType: string; + isAllowedEditing: boolean; + names: string[]; + excludeNames: string[]; + + onDeleteTableRow: () => void; + onEditTableRowData?: () => void; + onAcceptTableRowEditing: (rowData: SettingsFilterData) => void; +} + +export default function FilterTableRow(props: Props) { + // Table row state data + const [name, setName] = useState(props.chosenName); + const [type, setFiltrationType] = useState(props.chosenFiltrationType); + const [tableRowStatus, setTableRowStatus] = useState(props.status); + + // Table row methods + const changeHoverStatus = (isHovered = true) => () => { + if (props.status === FilterElementType.edited) { + return; + } + + if (isHovered) { + tableRowStatus !== FilterElementType.hovered && setTableRowStatus(FilterElementType.hovered); + } else { + setTableRowStatus(props.status); + } + }; + + const acceptRowEditing = () => { + props.onAcceptTableRowEditing({ + name, + type, + }); + }; + + // Effect hook for renew state data with props change + useEffect(() => { + setTableRowStatus(props.status); + setFiltrationType(props.chosenFiltrationType); + setName(props.chosenName); + }, [props.status, props.chosenFiltrationType, props.chosenName]); + + return ( +
+
+ setName(value)} + onClear={() => setName("")} + dropDownValues={props.names} + excludeValues={props.excludeNames.filter((exName) => exName !== props.chosenName)} + /> +
+ +
+
+ setFiltrationType(value)} + onClear={() => setFiltrationType("")} + dropDownValues={Object.values(FilterDropdownType)} + writable={false} + /> + + {tableRowStatus === FilterElementType.edited && ( + + )} +
+
+ + {tableRowStatus === FilterElementType.hovered && ( +
+ + + +
+ )} +
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/filter/components/table/table.scss b/frontend/src/app/modules/settings/parts/filter/components/table/table.scss new file mode 100644 index 0000000..aff8290 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/table/table.scss @@ -0,0 +1,4 @@ +.settings-filter-table { + margin-top: 15px; + box-sizing: border-box; +} diff --git a/frontend/src/app/modules/settings/parts/filter/components/table/table.tsx b/frontend/src/app/modules/settings/parts/filter/components/table/table.tsx new file mode 100644 index 0000000..83fce4f --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/components/table/table.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { SettingsFilterData } from "app/modules/settings/parts/filter/filter"; +import FilterTableRow from "app/modules/settings/parts/filter/components/table/table-row/table-row"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import "./table.scss"; + +interface Props { + tableRows: SettingsFilterData[]; + names: string[]; + excludeNames: string[]; + onAcceptTableRowEditing: (index: number, rowData: SettingsFilterData) => void; + onDeleteTableRow: (index: number) => void; +} + +export default function FilterTable(props: Props) { + // Table state + const invalidName = ""; + const [editName, setEditName] = useState(invalidName); + + // Table methods + const getTableRowParityStatus = (index: number, name: string) => { + if (name === editName) { + return FilterElementType.edited; + } + if (index % 2 === 1) { + return FilterElementType.odd; + } + return FilterElementType.even; + }; + + const editTableRowData = (name: string) => { + return editName === invalidName ? () => setEditName(name) : undefined; + }; + + const acceptTableRowEditing = (index: number) => (rowData: SettingsFilterData) => { + props.onAcceptTableRowEditing(index, rowData); + setEditName(invalidName); + }; + + const deleteTableRow = (index: number) => () => { + props.onDeleteTableRow(index); + }; + + return ( +
+ {props.tableRows.map(({ name, type }, index) => ( + + ))} +
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/filter/filter.scss b/frontend/src/app/modules/settings/parts/filter/filter.scss new file mode 100644 index 0000000..882e58b --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/filter.scss @@ -0,0 +1,8 @@ +@import "../../../../styles/colors"; + +.settings-filter { + font-weight: 500; + font-size: 14px; + line-height: 17px; + color: $darkGray; +} diff --git a/frontend/src/app/modules/settings/parts/filter/filter.tsx b/frontend/src/app/modules/settings/parts/filter/filter.tsx new file mode 100644 index 0000000..7a6aafe --- /dev/null +++ b/frontend/src/app/modules/settings/parts/filter/filter.tsx @@ -0,0 +1,115 @@ +import React, { Component } from "react"; +import "app/modules/settings/parts/filter/filter.scss"; +import { connect, ConnectedProps } from "react-redux"; +import { RootStore } from "app/common/types/store.types"; +import { SettingsDataUnion, SettingsSections } from "app/common/store/settings/types"; +import { caseInsensitiveStringCompare, deepCopyData } from "app/common/functions/helper"; +import SettingsLayout from "app/modules/settings/settings_layout/settings_layout"; +import FilterForm from "app/modules/settings/parts/filter/components/form/form"; +import FilterTable from "app/modules/settings/parts/filter/components/table/table"; + +export interface SettingsFilterData { + name: string; + type: string; +} + +interface SettingsFilterProps { + section: SettingsSections.filters | SettingsSections.qaFilters; + saveDataFunc: (data: SettingsDataUnion | any) => void; +} + +interface State { + filters: SettingsFilterData[]; + isSettingsDefault: boolean; +} + +class SettingsFilter extends Component { + constructor(props: Props) { + super(props); + this.state = this.getDefaultObjectState(); + } + + // Get default state method + getDefaultObjectState = () => { + const filters = deepCopyData( + this.props.defaultSettings[this.props.section].filter_settings.sort((a, b) => + caseInsensitiveStringCompare(a.name, b.name) + ) + ); + return { + filters, + isSettingsDefault: true, + }; + }; + + // Settings modification methods + addTableRow = (filter: SettingsFilterData) => { + const { filters } = this.state; + this.setState({ + filters: [...filters, filter], + isSettingsDefault: false, + }); + }; + + acceptTableRowEditing = (index: number, rowData: SettingsFilterData) => { + const filters = [...this.state.filters]; + filters[index] = { ...rowData }; + this.setState({ filters, isSettingsDefault: false }); + }; + + deleteTableRow = (index: number) => { + const { filters } = this.state; + filters.splice(index, 1); + this.setState({ filters, isSettingsDefault: false }); + }; + + // Layout function: Save and Clear methods + setDefaultFilters = () => { + this.setState(this.getDefaultObjectState()); + }; + + saveFilters = () => { + const { filters } = this.state; + this.props.saveDataFunc(filters); + this.setState({ isSettingsDefault: true }); + }; + + render() { + const { filters, isSettingsDefault } = this.state; + + const names = this.props.defaultSettings[this.props.section].names; + const excludeNames = filters.map((item) => item.name); + + return ( + +
+ + + +
+
+ ); + } +} + +const mapStateToProps = (store: RootStore) => ({ + defaultSettings: store.settings.settingsStore.defaultSettings, +}); + +const connector = connect(mapStateToProps); + +type Props = ConnectedProps & SettingsFilterProps; + +export default connector(SettingsFilter); diff --git a/frontend/src/app/modules/settings/parts/predictions/components/form/form.scss b/frontend/src/app/modules/settings/parts/predictions/components/form/form.scss new file mode 100644 index 0000000..1e64935 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/predictions/components/form/form.scss @@ -0,0 +1,37 @@ +@import "../../../../../../styles/colors"; + +.settings-predictions-form { + margin-top: 15px; + + &__title { + } + + &__wrapper { + display: flex; + align-items: center; + width: 50%; + margin-top: 5px; + } + + &__add-position { + display: inline-block; + padding: 6px; + margin-left: 15px; + border: 1.5px solid $gray; + background-color: transparent; + border-radius: 100%; + opacity: 0.5; + cursor: pointer; + transform: rotate(45deg); + transition: opacity 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out; + + &:hover:not([disabled]) { + opacity: 1; + color: $seaBlue; + border-color: $seaBlue; + } + &:disabled { + cursor: not-allowed; + } + } +} diff --git a/frontend/src/app/modules/settings/parts/predictions/components/form/form.tsx b/frontend/src/app/modules/settings/parts/predictions/components/form/form.tsx new file mode 100644 index 0000000..78d34c7 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/predictions/components/form/form.tsx @@ -0,0 +1,44 @@ +import React, { useState } from "react"; +import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import "./form.scss"; + +interface Props { + names: string[]; + excludeNames: string[]; + allowedAdding: boolean; + + onAddPredictionBlock: (prediction: string) => void; +} + +export default function PredictionsForm(props: Props) { + const [prediction, setPrediction] = useState(""); + + const addPredictionBlock = () => { + props.onAddPredictionBlock(prediction); + setPrediction(""); + }; + + return ( +
+

Add Own Element

+
+ setPrediction(value)} + onClear={() => setPrediction("")} + dropDownValues={props.names} + excludeValues={props.excludeNames} + /> + +
+
+ ); +} diff --git a/frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.scss b/frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.scss similarity index 95% rename from frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.scss rename to frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.scss index 4df9863..14f52f7 100644 --- a/frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.scss +++ b/frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.scss @@ -1,4 +1,4 @@ -@import "../../../../styles/colors"; +@import "../../../../../../styles/colors"; .input-predictions-element { display: flex; diff --git a/frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.tsx b/frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.tsx similarity index 91% rename from frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.tsx rename to frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.tsx index 53cd592..1d4ffbf 100644 --- a/frontend/src/app/modules/settings/elements/input-predictions-element/input-predictions-element.tsx +++ b/frontend/src/app/modules/settings/parts/predictions/components/predictions-container/predictions-container.tsx @@ -4,15 +4,15 @@ import React, { Component } from "react"; import { PredictionTableData } from "app/common/store/settings/types"; import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; import cn from "classnames"; -import "app/modules/settings/elements/input-predictions-element/input-predictions-element.scss"; +import "./predictions-container.scss"; -interface InputPredictionsElementProps { +interface Props { values: PredictionTableData[]; onDeletePrediction: (index: number) => () => void; onChangePredictionsOrder: (indexDrag: number, indexPaste: number) => void; } -export default class InputPredictionsElement extends Component { +export default class PredictionsContainer extends Component { predictionBlockRef: HTMLDivElement | undefined = undefined; predictionBlockDragStart = (e: React.DragEvent): void => { diff --git a/frontend/src/app/modules/settings/parts/predictions/predictions.scss b/frontend/src/app/modules/settings/parts/predictions/predictions.scss new file mode 100644 index 0000000..cb76b96 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/predictions/predictions.scss @@ -0,0 +1,5 @@ +.settings-predictions { + &__main { + margin-top: 15px; + } +} diff --git a/frontend/src/app/modules/settings/parts/predictions/predictions.tsx b/frontend/src/app/modules/settings/parts/predictions/predictions.tsx new file mode 100644 index 0000000..320b271 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/predictions/predictions.tsx @@ -0,0 +1,134 @@ +import React, { Component } from "react"; +import PredictionsContainer from "app/modules/settings/parts/predictions/components/predictions-container/predictions-container"; +import { connect, ConnectedProps } from "react-redux"; +import { RootStore } from "app/common/types/store.types"; +import { PredictionTableData } from "app/common/store/settings/types"; +import SettingsLayout from "app/modules/settings/settings_layout/settings_layout"; +import "app/modules/settings/parts/predictions/predictions.scss"; +import { sendSettingsPredictionsData } from "app/common/store/settings/thunks"; +import { deepCopyData } from "app/common/functions/helper"; +import PredictionsForm from "app/modules/settings/parts/predictions/components/form/form"; + +interface State { + predictions: PredictionTableData[]; + isSettingsDefault: boolean; +} + +class SettingsPredictions extends Component { + constructor(props: Props) { + super(props); + this.state = this.getDefaultObjectState(); + } + + // Get default state method + getDefaultObjectState = () => { + const predictions = deepCopyData( + this.props.defaultPredictions.predictions_table_settings + ); + return { + predictions, + isSettingsDefault: true, + }; + }; + + // Component methods + addPredictionBlock = (name: string) => { + const { predictions } = this.state; + const newPrediction: PredictionTableData = { + name, + is_default: false, + position: predictions.length + 1, + settings: 0, + }; + this.setState({ + predictions: [...predictions, newPrediction], + isSettingsDefault: false, + }); + }; + + deletePredictionBlock = (index: number) => () => { + const { predictions } = this.state; + predictions.splice(index, 1); + + this.setState({ + predictions: this.fixPredictionsBlocksOrder(predictions), + isSettingsDefault: false, + }); + }; + + fixPredictionsBlocksOrder = (predictionsArr: PredictionTableData[]) => { + return predictionsArr.map((item, index) => ({ ...item, position: index + 1 })); + }; + + changeValueBlocksOrder = (indexOfDraggedVal: number, indexOfNewPosition: number) => { + const { predictions } = this.state; + const val = predictions.splice(indexOfDraggedVal, 1)[0]; + predictions.splice(indexOfNewPosition, 0, val); + + this.setState({ + predictions: this.fixPredictionsBlocksOrder(predictions), + isSettingsDefault: false, + }); + }; + + // Layout function: Save and Clear methods + savePredictions = () => { + const { predictions } = this.state; + this.props.sendSettingsPredictionsData(predictions); + this.setState({ isSettingsDefault: true }); + }; + + setDefaultPredictions = () => { + this.setState(this.getDefaultObjectState()); + }; + + render() { + const { predictions, isSettingsDefault } = this.state; + + const defaultNames = this.props.defaultPredictions.field_names; + + const excludeNames = predictions.map((item) => item.name); + + const allowedPredictionAdding = + predictions.find((item: PredictionTableData) => !item.is_default) !== undefined; + + return ( + +
+ + +
+ +
+
+
+ ); + } +} + +const mapStateToProps = (store: RootStore) => ({ + defaultPredictions: store.settings.settingsStore.defaultSettings.predictions_table, +}); + +const mapDispatchToProps = { sendSettingsPredictionsData }; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +type Props = ConnectedProps; + +export default connector(SettingsPredictions); diff --git a/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.scss b/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.scss new file mode 100644 index 0000000..35baa1d --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.scss @@ -0,0 +1,34 @@ +@import "../../../../../../styles/colors"; + +.settings-training-bug-resolution { + &-row { + display: flex; + margin-top: 15px; + + &__metric, + &__value { + display: flex; + align-items: center; + font-size: 16px; + } + + &__metric { + width: 40%; + &-title { + margin-right: 20px; + } + .input-element__input { + color: $grayDisabledText; + border-color: $grayDisabled; + } + } + + &__value { + width: 60%; + &-title { + margin-right: 20px; + margin-left: 30px; + } + } + } +} diff --git a/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.tsx b/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.tsx new file mode 100644 index 0000000..d5a40c2 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/bug-resolution/bug-resolution.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import InputElement from "app/modules/settings/elements/input-element/input-element"; +import { BugResolutionElement } from "app/modules/settings/parts/training/store/types"; +import { useSelector } from "react-redux"; +import { RootStore } from "app/common/types/store.types"; +import "./bug-resolution.scss"; + +interface BugResolutionProps { + isAllowedEditing: boolean; + bugResolution: BugResolutionElement[]; + + onModifyBugResolution: (bugResolution: BugResolutionElement[]) => void; +} + +export default function BugResolution(props: BugResolutionProps) { + // Selector for extraction bug_resolution_names from training store + const bugResolutionNames = useSelector( + (store: RootStore) => store.settings.settingsTrainingStore.bug_resolution.resolution_names + ); + + const changeResolutionValue = (index: number) => (value: string) => { + const resolutionArr = props.bugResolution; + resolutionArr[index].value = value; + props.onModifyBugResolution([...resolutionArr]); + }; + + return ( +
+ + +
+ ); +} + +interface BugResolutionRowProps { + bugResolution: BugResolutionElement; + names: string[]; + isAllowedEditing: boolean; + excludeValue: string; + onChangeBugResolutionValue: (value: string) => void; +} + +// Section of bug resolution table +function BugResolutionRow(props: BugResolutionRowProps) { + return ( +
+
+

Metric

+ +
+
+

Value

+ props.onChangeBugResolutionValue("")} + /> +
+
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.scss b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.scss new file mode 100644 index 0000000..5866df7 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.scss @@ -0,0 +1,10 @@ +.settings-training-table { + &_disabled { + filter: opacity(0.75); + pointer-events: none; + } + + &-main { + margin-top: 15px; + } +} diff --git a/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.tsx b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.tsx new file mode 100644 index 0000000..a8bace6 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/mark-up-entities.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import cn from "classnames"; +import { MarkUpEntitiesElement } from "app/modules/settings/parts/training/store/types"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import TableRow from "app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row"; +import TableForm from "app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form"; +import "./mark-up-entities.scss"; + +interface Props { + markUpEntities: MarkUpEntitiesElement[]; + isAllowedEditing: boolean; + + onModifyMarkUpEntities: (entity: MarkUpEntitiesElement[]) => void; +} + +export default function MarkUpEntities(props: Props) { + // Component state + const invalidIndex = -1; + const [editIndex, setEditIndex] = useState(invalidIndex); + + // Method determines row's background-color + const getTableRowParityStatus = (index: number) => { + if (index === editIndex) { + return FilterElementType.edited; + } + if (index % 2 === 1) { + return FilterElementType.odd; + } + return FilterElementType.even; + }; + + // Handlers for markup_entities modification + const acceptTableRowDataEditing = (index: number) => (entity: MarkUpEntitiesElement) => { + const newMarkUpEntities = [...props.markUpEntities]; + newMarkUpEntities[index] = { ...entity }; + props.onModifyMarkUpEntities([...newMarkUpEntities]); + setEditIndex(invalidIndex); + }; + + const deleteTableRow = (index: number) => () => { + const newMarkUpEntities = [...props.markUpEntities]; + newMarkUpEntities.splice(index, 1); + props.onModifyMarkUpEntities([...newMarkUpEntities]); + }; + + const addTableRow = (entity: MarkUpEntitiesElement) => { + props.onModifyMarkUpEntities([...props.markUpEntities, entity]); + }; + + return ( +
+ +
+ {props.markUpEntities.map((entity: MarkUpEntitiesElement, index: number) => ( + setEditIndex(index)} + onDeleteTableRow={deleteTableRow(index)} + /> + ))} +
+
+ ); +} diff --git a/frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.scss b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.scss similarity index 59% rename from frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.scss rename to frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.scss index 9b9a8a5..ae174f4 100644 --- a/frontend/src/app/modules/settings/fields/settings_predictions/settings_predictions.scss +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.scss @@ -1,32 +1,30 @@ -@import "../../../../styles/colors"; +@import "../../../../../../../styles/colors"; -.settings-predictions { - font-weight: 500; - font-size: 14px; - line-height: 17px; - color: $darkGray; +.settings-training-table-form { + margin-top: 20px; - &-header { + &__wrapper { margin-top: 15px; + display: flex; + } - &__title { - } - - &__wrapper { - display: flex; - align-items: center; - width: 50%; - margin-top: 5px; - } + &__title { + font-size: 16px; + } + &__field-title { + font-size: 14px; + margin-bottom: 5px; } - &-main { - margin-top: 15px; + &-input { + width: 40%; } + &-select { + width: 60%; - &__button { - &:disabled { - cursor: not-allowed; + &__select-wrapper { + display: flex; + align-items: center; } } @@ -47,17 +45,9 @@ color: $seaBlue; border-color: $seaBlue; } - } - &__title { - font-size: 18px; - line-height: 22px; - color: $deepDarkBlue; - } - - &-footer { - display: flex; - justify-content: space-between; - margin-top: 30px; + &:disabled { + cursor: not-allowed; + } } } diff --git a/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.tsx b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.tsx new file mode 100644 index 0000000..9d3225e --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-form/table-form.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; +import InputElement from "app/modules/settings/elements/input-element/input-element"; +import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import InputEntityElement from "app/modules/settings/elements/input-entity-element/input-entity-element"; +import { RootStore } from "app/common/types/store.types"; +import { useSelector } from "react-redux"; +import { MarkUpEntitiesElement } from "app/modules/settings/parts/training/store/types"; +import "./table-form.scss"; + +interface Props { + isAllowedEditing: boolean; + onAddTableRow: (element: MarkUpEntitiesElement) => void; +} + +export default function TableForm(props: Props) { + // Selector for extraction entities names list from store + const entityNames = useSelector( + (store: RootStore) => store.settings.settingsTrainingStore.markup_entities.entity_names + ); + // Form's state + const [areaOfTesting, setAreaOfTesting] = useState(""); + const [entities, setEntities] = useState([]); + + // Methods for modification form state + const deleteEntity = (index: number) => { + entities.splice(index, 1); + setEntities([...entities]); + }; + const addEntity = (entity: string) => { + setEntities([...entities, entity]); + }; + + const addTableRow = () => { + props.onAddTableRow({ + entities, + area_of_testing: areaOfTesting, + }); + setEntities([]); + setAreaOfTesting(""); + }; + + return ( +
+
+
+

Name

+ setAreaOfTesting(value)} + onClear={() => setAreaOfTesting("")} + style={{ width: "90%" }} + /> +
+ +
+

Entities

+
+ setEntities([])} + dropDownValues={entityNames} + values={entities} + /> + +
+
+
+
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.scss b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.scss new file mode 100644 index 0000000..3394b16 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.scss @@ -0,0 +1,50 @@ +@import "../../../../../../../styles/colors"; + +.settings-training-table-row { + position: relative; + display: flex; + + &__select { + display: flex; + width: 60%; + } + + &__edit-wrapper { + position: absolute; + right: 0; + display: flex; + top: 50%; + transform: translateY(-50%); + } + + &__input { + width: 40%; + border-right: 1px solid $darkBlue; + } + &__accept-button { + background-color: $seaBlue; + width: 60px; + color: white; + display: flex; + justify-content: center; + align-items: center; + &:disabled { + cursor: not-allowed; + } + } + + &__edit-button, + &__delete-button { + background-color: $seaBlue; + color: rgba(255, 255, 255, 0.5); + transition: color 0.2s ease-out; + + &:hover { + color: rgba(255, 255, 255, 1); + } + + &:disabled { + cursor: not-allowed; + } + } +} diff --git a/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.tsx b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.tsx new file mode 100644 index 0000000..2844aa3 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/mark-up-entities/table-row/table-row.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import Icon, { IconType, IconSize } from "app/common/components/icon/icon"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import InputElement from "app/modules/settings/elements/input-element/input-element"; +import InputEntityElement from "app/modules/settings/elements/input-entity-element/input-entity-element"; +import { RootStore } from "app/common/types/store.types"; +import { MarkUpEntitiesElement } from "app/modules/settings/parts/training/store/types"; +import "./table-row.scss"; + +interface Props { + status: FilterElementType; + areaOfTesting: string; + markUpEntities: string[]; + isAllowedRowEditing: boolean; + isDisabled: boolean; + + onEditTableRowData: () => void; + onDeleteTableRow: () => void; + onAcceptTableRowDataEditing: (entity: MarkUpEntitiesElement) => void; +} + +export default function TableRow(props: Props) { + // Table row state data + const entitiesNames = useSelector( + (store: RootStore) => store.settings.settingsTrainingStore.markup_entities.entity_names + ); + const [tableRowStatus, setTableRowStatus] = useState(props.status); + const [areaOfTesting, setAreaOfTesting] = useState(props.areaOfTesting); + const [markUpEntities, setMarkUpEntities] = useState(props.markUpEntities); + + // Table row methods + const changeHoverStatus = (isHovered = true) => () => { + if (!props.isDisabled || props.status === FilterElementType.edited) { + return; + } + isHovered ? setTableRowStatus(FilterElementType.hovered) : setTableRowStatus(props.status); + }; + + const addEntity = (entity: string) => { + setMarkUpEntities([...markUpEntities, entity]); + }; + + const removeEntity = (index: number) => { + markUpEntities.splice(index, 1); + setMarkUpEntities([...markUpEntities]); + }; + + const removeAllEntities = () => { + setMarkUpEntities([]); + }; + + const acceptTableRowEditing = () => { + props.onAcceptTableRowDataEditing({ area_of_testing: areaOfTesting, entities: markUpEntities }); + }; + + // Effect hook for renew state data with props change + useEffect(() => { + setTableRowStatus(props.status); + setAreaOfTesting(props.areaOfTesting); + setMarkUpEntities(props.markUpEntities); + }, [props.status, props.areaOfTesting, props.markUpEntities]); + + return ( +
+
+ setAreaOfTesting("")} + /> +
+
+ + {tableRowStatus === FilterElementType.edited && ( + + )} +
+ + {tableRowStatus === FilterElementType.hovered && ( +
+ + + +
+ )} +
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.scss b/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.scss new file mode 100644 index 0000000..944d83b --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.scss @@ -0,0 +1,48 @@ +@import "../../../../../../styles/colors"; + +.settings-training-source { + display: flex; + align-items: center; + margin-top: 15px; + + &__title { + margin-right: 20px; + } + + &__arrow-left, + &__inscription, + &__check { + color: $orange; + margin: 0 20px; + } + + &__arrow-left { + animation: arrow_move 1s ease-in-out both alternate infinite; + } + + &__check { + color: $seaBlue; + } + + &__inscription { + margin-left: 0; + + &_success { + color: $seaBlue; + } + } + &__subtitle { + margin: 20px 0; + font-size: 18px; + color: $darkBlue; + } +} + +@keyframes arrow_move { + from { + transform: translateX(10px); + } + to { + transform: translateX(-10px); + } +} diff --git a/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.tsx b/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.tsx new file mode 100644 index 0000000..34684ed --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/components/source-field/source-field.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import cn from "classnames"; +import { ThunkDispatch } from "redux-thunk"; +import { AnyAction } from "redux"; +import Icon, { IconType, IconSize } from "app/common/components/icon/icon"; +import DropdownElement from "app/common/components/native-components/dropdown-element/dropdown-element"; +import { FilterElementType } from "app/modules/settings/elements/elements-types"; +import { useDispatch, useSelector } from "react-redux"; +import { RootStore } from "app/common/types/store.types"; +import { TrainingSubSection } from "app/modules/settings/parts/training/store/types"; +import { + sendSettingsTrainingData, + uploadSettingsTrainingSubfieldData, +} from "app/modules/settings/parts/training/store/thunks"; +import "./source-field.scss"; + +interface Props { + sourceField: string; + isAllowedEditing: boolean; + onModifySourceField: (sourceField: string) => void; +} + +export default function SourceField(props: Props) { + // Selector for extraction source_filed names from training store + const sourceFieldNames = useSelector( + (store: RootStore) => store.settings.settingsTrainingStore.source_field.source_field_names + ); + + // Function for thunk dispatching + const dispatch: ThunkDispatch = useDispatch(); + + // Uploading chosen source_field + const setMarkUpSource = async (source_field: string) => { + setTimeout(() => { + dispatch(sendSettingsTrainingData({ source_field }, TrainingSubSection.source_field)).then( + () => { + props.onModifySourceField(source_field); + dispatch(uploadSettingsTrainingSubfieldData(TrainingSubSection.markup_entities)); + } + ); + }, 1000); + }; + + return ( +
+

Source Field

+ props.onModifySourceField("")} + style={{ width: "30%" }} + /> + + {props.isAllowedEditing ? ( + + ) : ( + + )} + + + {props.isAllowedEditing ? "Source Field Saved" : "Set Source Field first to add Entities"} + +
+ ); +} diff --git a/frontend/src/app/modules/settings/parts/training/store/actions.ts b/frontend/src/app/modules/settings/parts/training/store/actions.ts new file mode 100644 index 0000000..3da9479 --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/store/actions.ts @@ -0,0 +1,39 @@ +import { SettingsStatuses } from "app/common/store/settings/types"; +import { + SettingsTrainingResponseType, + SettingsTrainingSendEntitiesAndResolution, + SettingsTrainingSendSourceField, + TrainingActionTypes, + TrainingSubSection, +} from "app/modules/settings/parts/training/store/types"; + +export const setSettingsTrainingData = ( + data: SettingsTrainingResponseType, + section: TrainingSubSection +) => + ({ + data, + section, + type: TrainingActionTypes.setSettingsTrainingData, + } as const); + +export const setSettingsTrainingStatus = (statuses: Partial) => + ({ + statuses, + type: TrainingActionTypes.setSettingsTrainingStatus, + } as const); + +export const clearSettingsTrainingData = () => + ({ type: TrainingActionTypes.clearSettingsTrainingData } as const); + +export const updateDefaultEntitiesAndResolutionData = (data: SettingsTrainingSendEntitiesAndResolution) => + ({ + data, + type: TrainingActionTypes.updateDefaultEntitiesAndResolutionData + } as const) + +export const updateDefaultSourceField = ({ source_field }: SettingsTrainingSendSourceField) => + ({ + source_field, + type: TrainingActionTypes.updateDefaultSourceField + } as const) \ No newline at end of file diff --git a/frontend/src/app/modules/settings/fields/settings_training/store/reducer.ts b/frontend/src/app/modules/settings/parts/training/store/reducer.ts similarity index 57% rename from frontend/src/app/modules/settings/fields/settings_training/store/reducer.ts rename to frontend/src/app/modules/settings/parts/training/store/reducer.ts index cb18706..d1c29aa 100644 --- a/frontend/src/app/modules/settings/fields/settings_training/store/reducer.ts +++ b/frontend/src/app/modules/settings/parts/training/store/reducer.ts @@ -2,11 +2,12 @@ import { TrainingActionTypes, SettingTrainingStore, -} from "app/modules/settings/fields/settings_training/store/types"; +} from "app/modules/settings/parts/training/store/types"; import { InferValueTypes } from "app/common/store/utils"; import { HttpStatus } from "app/common/types/http.types"; -import * as actions from "app/modules/settings/fields/settings_training/store/actions"; +import * as actions from "app/modules/settings/parts/training/store/actions"; +import { copyData, deepCopyData } from "app/common/functions/helper"; const initialState: SettingTrainingStore = { status: { @@ -38,15 +39,30 @@ export default function settingsTrainingReducer( switch (action.type) { case TrainingActionTypes.setSettingsTrainingData: if (!Object.keys(action.data).length) return { ...state }; - return { ...state, [action.section]: action.data }; + return { ...state, [action.section]: copyData(action.data) }; case TrainingActionTypes.setSettingsTrainingStatus: - status[action.section] = action.status; - return { ...state, status }; + return { + ...state, status: { + ...status, + ...action.statuses + } + }; case TrainingActionTypes.clearSettingsTrainingData: return { ...initialState }; + case TrainingActionTypes.updateDefaultEntitiesAndResolutionData: + const { bug_resolution, markup_entities } = state; + bug_resolution.resolution_settings = deepCopyData(action.data.bug_resolution); + markup_entities.mark_up_entities = deepCopyData(action.data.mark_up_entities); + return { ...state, bug_resolution, markup_entities }; + + case TrainingActionTypes.updateDefaultSourceField: + const { source_field } = state; + source_field.source_field = action.source_field; + return { ...state, source_field }; + default: return { ...state }; } diff --git a/frontend/src/app/modules/settings/parts/training/store/thunks.ts b/frontend/src/app/modules/settings/parts/training/store/thunks.ts new file mode 100644 index 0000000..dfbb77b --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/store/thunks.ts @@ -0,0 +1,77 @@ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +// TODO: Declare static types for this thunk + +import { SettingsApi } from "app/common/api/settings.api"; +import { SettingsTrainingSendEntitiesAndResolution, SettingsTrainingSendSourceField, SettingsTrainingSendType, TrainingSubSection } from "app/modules/settings/parts/training/store/types"; +import { addToast } from "app/modules/toasts-overlay/store/actions"; +import { ToastStyle } from "app/modules/toasts-overlay/store/types"; +import { HttpStatus } from "app/common/types/http.types"; +import { + setSettingsTrainingData, + setSettingsTrainingStatus, + updateDefaultSourceField, + updateDefaultEntitiesAndResolutionData, +} from "app/modules/settings/parts/training/store/actions"; +import { markModelNotTrained } from "app/common/store/common/thunks"; +import { uploadSignificantTermsData } from "app/common/store/analysis-and-training/thunks"; + +export const uploadSettingsTrainingSubfieldData = (subfield: TrainingSubSection) => { + return async (dispatch: any) => { + let res; + try { + res = await SettingsApi.getSettingsTrainingData(subfield); + } + catch (e) { + dispatch(addToast(e.message, ToastStyle.Error)); + dispatch(setSettingsTrainingStatus({ [subfield]: HttpStatus.FAILED })); + return; + } + dispatch(setSettingsTrainingData(res, subfield)); + dispatch(setSettingsTrainingStatus({ [subfield]: HttpStatus.FINISHED })); + return res; + } +} + +export const sendSettingsTrainingData = (data: SettingsTrainingSendType, subfield?: TrainingSubSection.source_field) => { + return async (dispatch: any) => { + try { + const res = await SettingsApi.sendSettingsTrainingDataData(data, subfield); + + if (subfield) { + dispatch(updateDefaultSourceField(data as SettingsTrainingSendSourceField)) + } else { + dispatch(updateDefaultEntitiesAndResolutionData(data as SettingsTrainingSendEntitiesAndResolution)); + } + + dispatch(markModelNotTrained()); + + if (!subfield) { + dispatch(uploadSignificantTermsData()); + } + + return res.result === "success"; + } + catch (e) { + dispatch(addToast(e.message, ToastStyle.Error)); + } + } +} + +export const uploadSettingsTrainingData = () => { + return async (dispatch: any) => { + try { + Object.values(TrainingSubSection).forEach(subfield => { + dispatch(setSettingsTrainingStatus({ [subfield]: HttpStatus.RELOADING })); + dispatch(uploadSettingsTrainingSubfieldData(subfield)); + }) + } + catch (e) { + dispatch(addToast(e.message, ToastStyle.Error)); + } + } +} diff --git a/frontend/src/app/modules/settings/fields/settings_training/store/types.ts b/frontend/src/app/modules/settings/parts/training/store/types.ts similarity index 73% rename from frontend/src/app/modules/settings/fields/settings_training/store/types.ts rename to frontend/src/app/modules/settings/parts/training/store/types.ts index fe6cdbd..1498fde 100644 --- a/frontend/src/app/modules/settings/fields/settings_training/store/types.ts +++ b/frontend/src/app/modules/settings/parts/training/store/types.ts @@ -8,11 +8,19 @@ export enum TrainingSubSection { bug_resolution = "bug_resolution", } +// Training status type + +export type TrainingStatus = { + [key in TrainingSubSection]: HttpStatus +} + // Action types export enum TrainingActionTypes { setSettingsTrainingData = "SET_SETTINGS_TRAINING_DATA", setSettingsTrainingStatus = "SET_SETTINGS_TRAINING_STATUS", clearSettingsTrainingData = "CLEAR_SETTINGS_TRAINING_DATA", + updateDefaultEntitiesAndResolutionData = "UPDATE_DEFAULT_ENTITIES_AND_RESOLUTION_DATA", + updateDefaultSourceField = "UPDATE_DEFAULT_SOURCE_FIELD" } // Source Field data type @@ -46,14 +54,6 @@ export interface BugResolutionData { resolution_names: string[]; } -// Training status type - -export interface TrainingStatus { - source_field: HttpStatus; - bug_resolution: HttpStatus; - markup_entities: HttpStatus; -} - // Training state type export interface SettingTrainingStore { @@ -69,3 +69,12 @@ export type SettingsTrainingResponseType = SourceFieldData | BugResolutionData | export interface SettingsTrainingResponseError { warning: string; } + +export type SettingsTrainingSendType = SettingsTrainingSendSourceField | SettingsTrainingSendEntitiesAndResolution + +export type SettingsTrainingSendSourceField = { source_field: string } + +export type SettingsTrainingSendEntitiesAndResolution = { + mark_up_entities: MarkUpEntitiesElement[], + bug_resolution: BugResolutionElement[] +} \ No newline at end of file diff --git a/frontend/src/app/modules/settings/parts/training/training.scss b/frontend/src/app/modules/settings/parts/training/training.scss new file mode 100644 index 0000000..f41f4ee --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/training.scss @@ -0,0 +1,14 @@ +@import "../../../../styles/colors"; + +.settings-training { + color: $darkGray; + font-size: 16px; + + &__subtitle { + font-weight: 500; + line-height: 22px; + margin: 20px 0; + font-size: 18px; + color: $darkBlue; + } +} diff --git a/frontend/src/app/modules/settings/parts/training/training.tsx b/frontend/src/app/modules/settings/parts/training/training.tsx new file mode 100644 index 0000000..37cdb2e --- /dev/null +++ b/frontend/src/app/modules/settings/parts/training/training.tsx @@ -0,0 +1,146 @@ +import React, { Component } from "react"; +import { connect, ConnectedProps } from "react-redux"; +import { RootStore } from "app/common/types/store.types"; +import { sendSettingsTrainingData } from "app/modules/settings/parts/training/store/thunks"; +import { + MarkUpEntitiesElement, + BugResolutionElement, +} from "app/modules/settings/parts/training/store/types"; +import SettingsLayout from "app/modules/settings/settings_layout/settings_layout"; +import { deepCopyData } from "app/common/functions/helper"; +import SourceField from "./components/source-field/source-field"; +import BugResolution from "./components/bug-resolution/bug-resolution"; +import MarkUpEntities from "./components/mark-up-entities/mark-up-entities"; +import "./training.scss"; + +interface State { + sourceField: string; + markupEntities: MarkUpEntitiesElement[]; + bugResolution: BugResolutionElement[]; + isSettingsDefault: boolean; +} + +class SettingsTraining extends Component { + constructor(props: Props) { + super(props); + this.state = this.getDefaultObjectState(); + } + + // Get default state method + getDefaultObjectState = () => { + const sourceField = this.props.settingsFromRedux.source_field.source_field; + const markupEntities = deepCopyData( + this.props.settingsFromRedux.markup_entities.mark_up_entities + ); + + const bugResolution = deepCopyData( + this.props.settingsFromRedux.bug_resolution.resolution_settings + ); + while (bugResolution.length < 2) { + bugResolution.push({ metric: "Resolution", value: "" }); + } + + return { + sourceField, + markupEntities, + bugResolution, + isSettingsDefault: true, + }; + }; + + // Methods wrappers for state modification + modifySourceField = (sourceField: string) => { + this.setState({ + sourceField, + }); + }; + + modifyBugResolution = (bugResolution: BugResolutionElement[]) => { + this.setState({ + bugResolution, + isSettingsDefault: false, + }); + }; + + modifyMarkUpEntities = (markupEntities: MarkUpEntitiesElement[]) => { + this.setState({ + markupEntities, + isSettingsDefault: false, + }); + }; + + // Layout methods + saveTraining = () => { + const data = { + mark_up_entities: this.state.markupEntities, + bug_resolution: this.state.bugResolution, + }; + + this.props.sendSettingsTrainingData(data); + this.setState({ isSettingsDefault: true }); + }; + + setDefaultTraining = () => { + this.setState({ ...this.getDefaultObjectState() }); + }; + + render() { + const { isSettingsDefault, sourceField, markupEntities, bugResolution } = this.state; + + // Check if each field - source_field, markup_entities and bug_resolution were filled + const isSaveButtonDisabled = !( + !isSettingsDefault && + sourceField.length && + markupEntities.length && + bugResolution.length && + bugResolution[0].value.length && + bugResolution[1].value.length + ); + + return ( + +
+

Areas of Testing

+ + + + + +

Bug Resolution

+ + +
+
+ ); + } +} + +const mapStateToProps = (store: RootStore) => ({ + settingsFromRedux: store.settings.settingsTrainingStore, +}); + +const mapDispatchToProps = { sendSettingsTrainingData }; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +type Props = ConnectedProps; + +export default connector(SettingsTraining); diff --git a/frontend/src/app/modules/settings/sections/analysis-and-training-section/analysis-and-training-section.tsx b/frontend/src/app/modules/settings/sections/analysis-and-training-section/analysis-and-training-section.tsx index 5343c66..b8c49af 100644 --- a/frontend/src/app/modules/settings/sections/analysis-and-training-section/analysis-and-training-section.tsx +++ b/frontend/src/app/modules/settings/sections/analysis-and-training-section/analysis-and-training-section.tsx @@ -1,32 +1,38 @@ import React, { Component } from "react"; import CircleSpinner from "app/common/components/circle-spinner/circle-spinner"; -import SettingsFilter from "app/modules/settings/fields/settings_filter/setings_filter"; +import SettingsFilter from "app/modules/settings/parts/filter/filter"; import "app/modules/settings/sections/analysis-and-training-section/analysis-and-training-section.scss"; import { SettingsSections } from "app/common/store/settings/types"; -import { uploadSettingsData } from "app/common/store/settings/thunks"; -import { uploadSettingsTrainingData } from "app/modules/settings/fields/settings_training/store/thunks"; +import { + uploadSettingsATFilterData, + sendSettingsATFiltersData, +} from "app/common/store/settings/thunks"; +import { uploadSettingsTrainingData } from "app/modules/settings/parts/training/store/thunks"; import { connect, ConnectedProps } from "react-redux"; import { RootStore } from "app/common/types/store.types"; import { HttpStatus } from "app/common/types/http.types"; -import SettingsTraining from "app/modules/settings/fields/settings_training/settings_training"; +import SettingsTraining from "app/modules/settings/parts/training/training"; class AnalysisAndTrainingSection extends Component { componentDidMount = () => { - const { uploadSettingsData, uploadSettingsTrainingData } = this.props; + const { uploadSettingsATFilterData, uploadSettingsTrainingData } = this.props; // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSettingsData(SettingsSections.filters); + uploadSettingsATFilterData(); // eslint-disable-next-line @typescript-eslint/no-floating-promises uploadSettingsTrainingData(); }; render() { - const { status, trainingStatus } = this.props; + const { status, trainingStatus, sendSettingsATFiltersData } = this.props; return (
{status[SettingsSections.filters] === HttpStatus.FINISHED && (
- +
)} {status[SettingsSections.filters] === HttpStatus.RELOADING && ( @@ -61,8 +67,9 @@ const mapStateToProps = ({ settings }: RootStore) => ({ }); const mapDispatchToProps = { - uploadSettingsData, + uploadSettingsATFilterData, uploadSettingsTrainingData, + sendSettingsATFiltersData, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/src/app/modules/settings/sections/qa-metrics-section/qa-metrics-section.tsx b/frontend/src/app/modules/settings/sections/qa-metrics-section/qa-metrics-section.tsx index 29525c7..adbc68f 100644 --- a/frontend/src/app/modules/settings/sections/qa-metrics-section/qa-metrics-section.tsx +++ b/frontend/src/app/modules/settings/sections/qa-metrics-section/qa-metrics-section.tsx @@ -1,25 +1,29 @@ import React, { Component } from "react"; import { SettingsSections } from "app/common/store/settings/types"; -import { uploadSettingsData } from "app/common/store/settings/thunks"; +import { + uploadSettingsQAMetricsFilterData, + uploadSettingsPredictionsData, + sendSettingsQAMetricsFiltersData, +} from "app/common/store/settings/thunks"; import { connect, ConnectedProps } from "react-redux"; import { RootStore } from "app/common/types/store.types"; import { HttpStatus } from "app/common/types/http.types"; import CircleSpinner from "app/common/components/circle-spinner/circle-spinner"; -import SettingsFilter from "app/modules/settings/fields/settings_filter/setings_filter"; -import SettingsPredictions from "app/modules/settings/fields/settings_predictions/settings_predictions"; +import SettingsFilter from "app/modules/settings/parts/filter/filter"; +import SettingsPredictions from "app/modules/settings/parts/predictions/predictions"; import "app/modules/settings/sections/qa-metrics-section/qa-metrics-section.scss"; class QAMetricsSection extends Component { componentDidMount = () => { - const { uploadSettingsData } = this.props; + const { uploadSettingsQAMetricsFilterData, uploadSettingsPredictionsData } = this.props; // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSettingsData(SettingsSections.qaFilters); + uploadSettingsQAMetricsFilterData(); // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSettingsData(SettingsSections.predictions); + uploadSettingsPredictionsData(); }; render() { - const { status } = this.props; + const { status, sendSettingsQAMetricsFiltersData } = this.props; return (
@@ -35,7 +39,10 @@ class QAMetricsSection extends Component { )} {status[SettingsSections.qaFilters] === HttpStatus.FINISHED && (
- +
)} {status[SettingsSections.qaFilters] === HttpStatus.RELOADING && ( @@ -52,7 +59,11 @@ const mapStateToProps = ({ settings }: RootStore) => ({ status: settings.settingsStore.status, }); -const mapDispatchToProps = { uploadSettingsData }; +const mapDispatchToProps = { + uploadSettingsQAMetricsFilterData, + uploadSettingsPredictionsData, + sendSettingsQAMetricsFiltersData, +}; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/src/app/modules/settings/settings_layout/settings_layout.scss b/frontend/src/app/modules/settings/settings_layout/settings_layout.scss new file mode 100644 index 0000000..4075be7 --- /dev/null +++ b/frontend/src/app/modules/settings/settings_layout/settings_layout.scss @@ -0,0 +1,21 @@ +@import "../../../styles/colors"; + +.settings-layout { + font-weight: 500; + font-size: 14px; + line-height: 17px; + color: $darkGray; + + &__title { + font-weight: 500; + font-size: 20px; + line-height: 22px; + color: $deepDarkBlue; + } + + &__footer { + display: flex; + justify-content: space-between; + margin-top: 30px; + } +} \ No newline at end of file diff --git a/frontend/src/app/modules/settings/settings_layout/settings_layout.tsx b/frontend/src/app/modules/settings/settings_layout/settings_layout.tsx new file mode 100644 index 0000000..e5f404f --- /dev/null +++ b/frontend/src/app/modules/settings/settings_layout/settings_layout.tsx @@ -0,0 +1,43 @@ +import Button, { ButtonStyled } from "app/common/components/button/button"; +import { IconType, IconSize } from "app/common/components/icon/icon"; +import "./settings_layout.scss"; +import React from "react"; + +interface SettingsLayoutProps { + title: string; + children: React.ReactNode; + + cancelButtonTitle?: string; + cancelButtonDisable: boolean; + cancelButtonHandler: () => void; + + saveButtonTitle?: string; + saveButtonDisable: boolean; + saveButtonHandler: () => void; +} + +export default function SettingsLayout(props: SettingsLayoutProps) { + return ( +
+

{props.title}

+ {props.children} +
+
+
+ ); +} diff --git a/frontend/src/app/modules/sidebar/sidebar.tsx b/frontend/src/app/modules/sidebar/sidebar.tsx index 7860cb0..2f8329c 100644 --- a/frontend/src/app/modules/sidebar/sidebar.tsx +++ b/frontend/src/app/modules/sidebar/sidebar.tsx @@ -1,15 +1,13 @@ import Icon, { IconSize, IconType } from "app/common/components/icon/icon"; - -import "app/modules/sidebar/sidebar.scss"; -import { deleteUser } from "app/common/store/auth/actions"; +import Tooltip from "app/common/components/tooltip/tooltip"; +import { logout } from "app/common/store/auth/thunks"; import { activateSettings } from "app/common/store/settings/actions"; -import { - activateVirtualAssistant, - clearMessages, -} from "app/common/store/virtual-assistant/actions"; +import { activateVirtualAssistant } from "app/common/store/virtual-assistant/actions"; import { RouterNames } from "app/common/types/router.types"; import { RootStore } from "app/common/types/store.types"; +import "app/modules/sidebar/sidebar.scss"; + import arrowIcon from "assets/icons/arrow.icon.svg"; import logoFull from "assets/images/logo-full.svg"; import logo from "assets/images/logo.svg"; @@ -18,12 +16,6 @@ import cn from "classnames"; import React from "react"; import { connect, ConnectedProps } from "react-redux"; import { Link, RouteComponentProps } from "react-router-dom"; -import Tooltip from "app/common/components/tooltip/tooltip"; -import { - setStatusTrainModelQAMetrics, - clearQAMetricsData, -} from "app/common/store/qa-metrics/actions"; -import { clearSettingsData } from "app/common/store/settings/thunks"; enum SideBarTabs { settings = "Settings", @@ -78,10 +70,7 @@ class Sidebar extends React.Component { }; logOut = () => { - this.props.deleteUser(); - this.props.clearQAMetricsData(); - this.props.clearSettingsData(); - this.props.clearMessages(); + this.props.logout(); }; render() { @@ -136,14 +125,14 @@ class Sidebar extends React.Component {
{/* excluded section */} {/* -
- - Account Management -
*/} +
+ + Account Management +
*/}
); } diff --git a/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer-render-function/parse-message-function.tsx b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer-render-function/parse-message-function.tsx new file mode 100644 index 0000000..80bc53d --- /dev/null +++ b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer-render-function/parse-message-function.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import cn from "classnames"; + +export default function parseMessage(text: Array, parseFunc?: any) { + if (parseFunc) { + return text.map((item) => { + if (typeof item === "string") return parseFunc(item); + return item; + }); + } + + return text.map((textItem) => { + if (typeof textItem === "string") { + let parsedText: Array = [textItem]; + Object.values(parseFunctions).forEach((func) => { + parsedText = parseMessage(parsedText, func); + }); + return parsedText; + } + return textItem; + }); +} + +const parseFunctions = { + parseUlDash: (text: string) => parseList(text, ListType.dash), + parseUlPoint: (text: string) => parseList(text, ListType.point), + parseRef, +}; + +const ListType = { + point: { + isOrdered: false, + regexSymbol: "*", + className: "point", + }, + dash: { + isOrdered: false, + regexSymbol: "-", + className: "dash", + }, +}; + +function parseList(text: string, listType: typeof ListType[keyof typeof ListType]) { + const constructList = (startListPosition: number) => { + return ( +
    + {parsedText.splice(startListPosition)} +
+ ); + }; + + const listRegex = new RegExp("\\" + listType.regexSymbol + " .*?\n", "g"); + + const matchRegex = text.match(listRegex); + const splitRegex = text.split(listRegex); + + if (!matchRegex) return text; + + const parsedText: Array = []; + + let isListOpened = false; + let startListPosition = 0; + + splitRegex.forEach((item) => { + if (item) { + if (isListOpened) { + isListOpened = false; + parsedText.push(constructList(startListPosition)); + } + + parsedText.push(...parseMessage([item])); + } + + if (!isListOpened) { + startListPosition = parsedText.length; + isListOpened = true; + } + + const liContent = matchRegex.shift()?.slice(2, -1); + + if (liContent) { + parsedText.push(
  • {parseMessage([liContent])}
  • ); + } + }); + if (isListOpened) { + parsedText.push(constructList(startListPosition)); + } + return parsedText; +} + +function parseRef(text: string) { + // the common pattern to render app's link is [link text](link ref), example: [follow the link](http://localhost/) + const linkRegex = /\[.*?\]\(.*?\)/g; + + const linkArr: any = text.match(linkRegex); + + if (!linkArr) return text; + // if link is found in message, then divide initial message by the link pattern and render in pairs [text that isn't the link that has been gotten by the division] - [the link] + + const refMessageArr: Array = []; + const textArr: string[] = text.split(linkRegex); + + const linkTextRegex = /[^[]+(?=\])/g; + const linkRefRegex = /[^(]+(?=\))/g; + textArr.forEach((item: string, index: number) => { + let linkText: string | null = null; + let linkRef: string | null = null; + const link: any = linkArr[index]; + if (link) { + linkText = link.match(linkTextRegex)[0]; + linkRef = link.match(linkRefRegex)[0]; + } + refMessageArr.push(...parseMessage([item])); + if (linkText && linkRef) + refMessageArr.push( + + {linkText} + + ); + }); + + return refMessageArr; +} diff --git a/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.scss b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.scss index 1586452..b509a08 100644 --- a/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.scss +++ b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.scss @@ -61,6 +61,21 @@ } } } + + &__ul { + &-dash { + list-style-type: none; + > li { + &:before { + content: " - "; + } + } + } + &-point { + list-style-type: disc; + list-style-position: inside; + } + } } &-typing-preview { @@ -137,6 +152,7 @@ } &-choice-list { + margin-top: 15px; width: 100%; margin-right: 10px; display: flex; @@ -165,11 +181,6 @@ } } - &-choice-wrapper { - min-height: 30px; - margin-top: 15px; - } - &-dropdown-list { display: flex; max-width: 40%; @@ -298,38 +309,36 @@ } } - &-widget{ - - &-buttons{ - display: flex; - flex-direction: column; - margin: 10px 0; - - &__send{ - align-self: flex-end; - color: white; - background-color: $blue; - font-weight: 500; - margin-right: 10px; - - &_disabled{ - color: white !important; - opacity: 1 !important; - background: $grayDisabled !important ; - } - - &_shifted{ - margin-right: -10px; - margin-bottom: -10px; - } - - &:hover:not(&_disabled) { - background-color: $activeHover; - } - } - } - } + &-widget { + &-buttons { + display: flex; + flex-direction: column; + margin: 10px 0; + + &__send { + align-self: flex-end; + color: white; + background-color: $blue; + font-weight: 500; + margin-right: 10px; + + &_disabled { + color: white !important; + opacity: 1 !important; + background: $grayDisabled !important ; + } + + &_shifted { + margin-right: -10px; + margin-bottom: -10px; + } + &:hover:not(&_disabled) { + background-color: $activeHover; + } + } + } + } } .icon-Left-Arrow { @@ -348,4 +357,3 @@ display: none; } } - diff --git a/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.tsx b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.tsx index c69b8c8..4806531 100644 --- a/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.tsx +++ b/frontend/src/app/modules/virtual-assistant/message-viewer/message-viewer.tsx @@ -70,11 +70,9 @@ export default function MessageViewer(props: Props) { )}
    -
    - {choiceList && ( - - )} -
    + {choiceList && ( + + )} {isTyping && (
    diff --git a/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.scss b/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.scss index 7fdad51..87038e1 100644 --- a/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.scss +++ b/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.scss @@ -5,15 +5,37 @@ margin-top: 20px; } + .header__title { + width: 0; + white-space: nowrap; + } + &__header-container { + width: 100%; + display: flex; + padding-left: calc(47.5862% - 260px); + } + + &__train-panel { display: flex; - position: absolute; - right: 50%; - transform: translateX(220px); + align-items: center; + margin-left: 20px; } - &__train-button { - margin-left: 30px; + &__explanatory-mark { + display: flex; + align-items: center; + justify-content: center; + margin-left: 10px; + + color: $orange; + font-size: 100%; + + width: 24px; + height: 24px; + + border: 2px solid $orange; + border-radius: 100%; } &__content { @@ -66,6 +88,8 @@ &__section-filters { z-index: 0; + height: 50px; + width: 124px; } &__filters { @@ -123,7 +147,7 @@ border-radius: 20px; overflow: hidden; } - + &__loader-inner { height: inherit; background-color: $blue; @@ -150,7 +174,6 @@ .frequently-used-terms { height: 215px !important; } - } } @@ -167,6 +190,5 @@ .defect-submission-card { height: 290px !important; } - } } diff --git a/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.tsx b/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.tsx index 9fcb291..6c130f3 100644 --- a/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.tsx +++ b/frontend/src/app/pages/analysis-and-training/analysis-and-training.page.tsx @@ -1,25 +1,25 @@ -import { AnalysisAndTrainingApi } from "app/common/api/analysis-and-training.api"; +import { SocketEventType } from "app/common/api/sockets"; import Button, { ButtonStyled } from "app/common/components/button/button"; import Card from "app/common/components/card/card"; import { IconType } from "app/common/components/icon/icon"; +import Tooltip, { TooltipPosition } from "app/common/components/tooltip/tooltip"; import { Timer } from "app/common/functions/timer"; -import { setCollectingDataStatus } from "app/common/store/settings/actions"; import { - AnalysisAndTrainingDefectSubmission, - AnalysisAndTrainingStatistic, - DefectSubmissionData, - SignificantTermsData, -} from "app/common/types/analysis-and-training.types"; -import { HttpStatus } from "app/common/types/http.types"; + updateFilters, + uploadDashboardData, + uploadDefectSubmission, + uploadSignificantTermsList, +} from "app/common/store/analysis-and-training/thunks"; +import { checkIssuesStatus } from "app/common/store/common/thunks"; +import { checkIssuesExist } from "app/common/store/common/utils"; +import { RootStore } from "app/common/types/store.types"; import DefectSubmission from "app/modules/defect-submission/defect-submission"; import { FilterFieldBase } from "app/modules/filters/field/field-type"; -import { checkFieldIsFilled } from "app/modules/filters/field/field.helper-function"; -import { Filters, FiltersPopUp } from "app/modules/filters/filters"; +import { Filters } from "app/modules/filters/filters"; import FrequentlyUsedTerms from "app/modules/frequently-used-terms/frequently-used-terms"; import Header from "app/modules/header/header"; -import MainStatistic, { MainStatisticData } from "app/modules/main-statistic/main-statistic"; +import MainStatistic from "app/modules/main-statistic/main-statistic"; import SignificantTerms from "app/modules/significant-terms/significant-terms"; -import { Terms } from "app/modules/significant-terms/store/types"; import Statistic from "app/modules/statistic/statistic"; import { addToast, @@ -46,24 +46,6 @@ import { connect, ConnectedProps } from "react-redux"; interface State { loadingStatus: number; - filters: FilterFieldBase[]; - totalStatistic: MainStatisticData | undefined; - frequentlyTerms: string[]; - isCollectingFinished: boolean; - statistic: AnalysisAndTrainingStatistic | undefined; - significantTerms: SignificantTermsData; - defectSubmission: DefectSubmissionData; - statuses: { - [key: string]: HttpStatus; - filter: HttpStatus; - frequentlyTerms: HttpStatus; - defectSubmission: HttpStatus; - statistic: HttpStatus; - significantTerms: HttpStatus; - }; - warnings: { - frequentlyTerms: string; - }; } const LOADING_TIME = 5 * 60 * 1000; @@ -78,38 +60,11 @@ class AnalysisAndTrainingPage extends React.Component { readonly state: Readonly = { loadingStatus: 0, - filters: [], - totalStatistic: undefined, - frequentlyTerms: [], - isCollectingFinished: true, - statistic: undefined, - significantTerms: { - metrics: [], - chosen_metric: null, - terms: {}, - }, - defectSubmission: { - data: undefined, - activePeriod: undefined, - }, - statuses: { - filter: HttpStatus.PREVIEW, - frequentlyTerms: HttpStatus.PREVIEW, - defectSubmission: HttpStatus.PREVIEW, - statistic: HttpStatus.PREVIEW, - significantTerms: HttpStatus.PREVIEW, - }, - warnings: { - frequentlyTerms: "", - }, }; constructor(innerProps: PropsFromRedux) { super(innerProps); - const { props, state } = this; - props.setCollectingDataStatus(state.isCollectingFinished); - switch (Math.floor(Math.random() * 3)) { case 1: this.imageForCalculating = calculateData2; @@ -125,25 +80,16 @@ class AnalysisAndTrainingPage extends React.Component { componentDidMount(): void { this.isComponentMounted = true; - const { isCollectingFinished } = this.state; - - this.uploadTotalStatistic().then((filtered) => { - if (isCollectingFinished) { - if (filtered) { - this.uploadDashboardData("full"); - } else { - this.uploadDashboardData("filters"); - } - } - }); this.startSocket(); + + if (!this.props.totalStatistic) { + this.props.uploadDashboardData(); + } } componentDidUpdate(): void { - const { isCollectingFinished } = this.state; - - if (!isCollectingFinished && !this.interval) { + if (this.props.isLoadedIssuesStatus && !this.props.isIssuesExist && !this.interval) { this.interval = setInterval(() => { this.setState((state) => ({ loadingStatus: state.loadingStatus < 100 ? state.loadingStatus + 1 : 100, @@ -159,381 +105,75 @@ class AnalysisAndTrainingPage extends React.Component { } componentWillUnmount = () => { - const { props } = this; - - this.isComponentMounted = false; if (this.updateDataToastID) { - props.removeToastByOuterId(this.updateDataToastID); - } - }; - - uploadDashboardData = (typeUpload: "full" | "data" | "filters" = "data") => { - if (typeUpload === "full" || typeUpload === "filters") { - this.uploadFilters(); + this.props.removeToastByOuterId(this.updateDataToastID); } - if (typeUpload === "full" || typeUpload === "data") { - this.uploadFrequentlyTerms(); - this.uploadStatistic(); - this.uploadDefectSubmission(); - this.uploadSignificantTermsData(); - } + socket.unsubscribe(SocketEventType.updateCountIssues); }; startSocket = () => { - const { props, state } = this; - - socket.startMonitor("message", () => { - if (state.isCollectingFinished && this.isComponentMounted) { - this.updateDataToastID += 1; - props.addToastWithAction( - "Data has been updated", - ToastStyle.Info, - [ - { - buttonName: "Load", - callBack: () => { - this.uploadTotalStatistic().then((_) => this.uploadDashboardData("full")); - }, - }, - ], - this.updateDataToastID - ); - } else if (!state.isCollectingFinished) { - document.location.reload(); - } - }); - }; - - validateUploadData = (data: any, cardName: string) => { - if (!(data && Object.keys(data).length)) { - this.setState((state) => ({ - statuses: { ...state.statuses, [cardName]: HttpStatus.PREVIEW }, - })); - return true; - } - return false; - }; - - uploadSignificantTermsData = async () => { - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.LOADING }, - })); - - let significant_terms: SignificantTermsData; - - try { - significant_terms = (await AnalysisAndTrainingApi.getSignificantTermsData()) - .significant_terms; - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.FAILED }, - })); - return; - } - - if (this.validateUploadData(significant_terms, "significantTerms")) return; - - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.FINISHED }, - significantTerms: { - metrics: [...significant_terms.metrics], - chosen_metric: significant_terms.chosen_metric, - terms: { ...significant_terms.terms }, - }, - })); - }; - - uploadSignificantTermsList = async (metric: string) => { - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.LOADING }, - significantTerms: { - ...state.significantTerms, - chosen_metric: metric, - }, - })); - - let significant_terms: Terms; - - try { - significant_terms = (await AnalysisAndTrainingApi.getSignificantTermsList(metric)) - .significant_terms; - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.FAILED }, - })); - return; - } - - if (this.validateUploadData(significant_terms, "significantTerms")) return; - - this.setState((state) => ({ - statuses: { ...state.statuses, significantTerms: HttpStatus.FINISHED }, - significantTerms: { - ...state.significantTerms, - terms: { ...significant_terms }, - }, - })); - }; - - uploadDefectSubmission = async (period?: string) => { - this.setState((state) => ({ - defectSubmission: { - ...state.defectSubmission, - activePeriod: period, - }, - statuses: { ...state.statuses, defectSubmission: HttpStatus.LOADING }, - })); - - let defectSubmission: AnalysisAndTrainingDefectSubmission; - - try { - defectSubmission = await AnalysisAndTrainingApi.getDefectSubmission(period); - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, defectSubmission: HttpStatus.FAILED }, - })); - return; - } - - if (this.validateUploadData(defectSubmission, "defectSubmission")) return; - - this.setState((state) => ({ - defectSubmission: { - data: defectSubmission, - activePeriod: defectSubmission!.period, - }, - statuses: { ...state.statuses, defectSubmission: HttpStatus.FINISHED }, - })); - }; - - uploadFrequentlyTerms = async () => { - this.setState((state) => ({ - statuses: { ...state.statuses, frequentlyTerms: HttpStatus.LOADING }, - })); - - let body: any; - - try { - body = await AnalysisAndTrainingApi.getFrequentlyTerms(); - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, frequentlyTerms: HttpStatus.FAILED }, - })); - return; - } - - if (this.validateUploadData(body, "frequentlyTerms")) return; - - if (body.frequently_terms) { - this.setState((state) => ({ - frequentlyTerms: [...body.frequently_terms], - statuses: { ...state.statuses, frequentlyTerms: HttpStatus.FINISHED }, - })); - } else { - this.setState((state) => ({ - warnings: { - ...state.warnings, - frequentlyTerms: body.warning.detail || body.warning.message, - }, - statuses: { ...state.statuses, frequentlyTerms: HttpStatus.WARNING }, - })); - } - }; - - uploadFilters = async () => { - this.setState((state) => ({ - statuses: { ...state.statuses, filter: HttpStatus.LOADING }, - })); - - let filters: FilterFieldBase[]; - - try { - filters = await AnalysisAndTrainingApi.getFilter(); - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, filter: HttpStatus.FAILED }, - })); - return; - } - - this.setState((state) => ({ - filters: [...filters], - statuses: { ...state.statuses, filter: HttpStatus.FINISHED }, - })); - }; - - applyFilters = async (filters: FilterFieldBase[]) => { - this.setState((state) => ({ - filters: [], - statuses: { ...state.statuses, filter: HttpStatus.LOADING }, - })); - - let response: { filters: FilterFieldBase[]; records_count: MainStatisticData | undefined }; - - try { - response = await AnalysisAndTrainingApi.saveFilter({ - action: "apply", - filters: [ - ...filters.filter((field) => - checkFieldIsFilled(field.filtration_type, field.current_value) - ), - ], - }); - } catch (e) { - this.setState((state) => ({ - filters, - statuses: { ...state.statuses, filter: HttpStatus.FAILED }, - })); - return; - } - - if (response.records_count && response.records_count.filtered) { - this.setState((state) => ({ - filters: response.filters, - totalStatistic: response.records_count, - statuses: { ...state.statuses, filter: HttpStatus.FINISHED }, - })); - - this.uploadDashboardData(); - } else { - this.props.addToast(FiltersPopUp.noDataFound, ToastStyle.Warning); - - this.setState((state) => ({ - filters: response.filters, - statuses: { ...state.statuses, filter: HttpStatus.FINISHED }, - })); - } - }; - - resetFilters = async () => { - this.setState((state) => ({ - filters: [], - statuses: { ...state.statuses, filter: HttpStatus.LOADING }, - })); + socket.subscribeToEvent(SocketEventType.updateCountIssues, () => { + checkIssuesExist().then((isIssuesExist) => { + if (!isIssuesExist) { + this.props.checkIssuesStatus(); + this.props.uploadDashboardData(); + } else { + this.updateDataToastID += 1; - let response: { filters: FilterFieldBase[]; records_count: MainStatisticData | undefined }; + const toast = { + message: "Data has been updated", + style: ToastStyle.Info, + buttons: [{ buttonName: "Load", callBack: this.props.uploadDashboardData }], + outerId: this.updateDataToastID, + }; - try { - response = await AnalysisAndTrainingApi.saveFilter({ - action: "apply", - filters: [], + this.props.addToastWithAction(toast); + } }); - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, filter: HttpStatus.FAILED }, - })); - return; - } - - this.setState((state) => ({ - filters: response.filters, - totalStatistic: response.records_count, - statuses: { ...state.statuses, filter: HttpStatus.FINISHED }, - })); - - this.uploadDashboardData(); + }); }; - uploadStatistic = async () => { - this.setState((state) => ({ - statuses: { ...state.statuses, statistic: HttpStatus.LOADING }, - })); - - let statistic: AnalysisAndTrainingStatistic | undefined; - - try { - statistic = await AnalysisAndTrainingApi.getStatistic(); - } catch (e) { - this.setState((state) => ({ - statuses: { ...state.statuses, statistic: HttpStatus.FAILED }, - })); - return; - } - - if (this.validateUploadData(statistic, "statistic")) return; - - this.setState((state) => ({ - statistic, - statuses: { ...state.statuses, statistic: HttpStatus.FINISHED }, - })); + applyFilters = (filters: FilterFieldBase[]) => { + this.props.updateFilters(filters); }; - uploadTotalStatistic = async () => { - const { props } = this; - - this.setState({ - statuses: { - filter: HttpStatus.LOADING, - frequentlyTerms: HttpStatus.LOADING, - defectSubmission: HttpStatus.LOADING, - statistic: HttpStatus.LOADING, - significantTerms: HttpStatus.LOADING, - }, - }); - - const totalStatistic = await AnalysisAndTrainingApi.getTotalStatistic(); - - // check that data is collected - if (totalStatistic.records_count) { - // there is data - this.setState({ - totalStatistic: { ...totalStatistic.records_count }, - }); - - if (!totalStatistic.records_count.filtered) { - props.addToast( - "With cached filters we didn't find data. Try to change filter.", - ToastStyle.Warning - ); - - this.setState((state) => ({ - isCollectingFinished: true, - statuses: { - ...state.statuses, - frequentlyTerms: HttpStatus.PREVIEW, - defectSubmission: HttpStatus.PREVIEW, - statistic: HttpStatus.PREVIEW, - significantTerms: HttpStatus.PREVIEW, - }, - })); - props.setCollectingDataStatus(true); - } - } else { - // there isn't data - this.setState({ - isCollectingFinished: false, - statuses: { - filter: HttpStatus.PREVIEW, - frequentlyTerms: HttpStatus.PREVIEW, - defectSubmission: HttpStatus.PREVIEW, - statistic: HttpStatus.PREVIEW, - significantTerms: HttpStatus.PREVIEW, - }, - }); - props.setCollectingDataStatus(false); - } - - return totalStatistic.records_count ? totalStatistic.records_count.filtered : 0; + resetFilters = () => { + this.props.updateFilters([]); }; render() { const { state } = this; + const showCoffeemaker = + !this.props.totalStatistic && this.props.isLoadedIssuesStatus && !this.props.isIssuesExist; + const blurIntensive = 10 - (state.loadingStatus / 100) * 9; - const style = state.isCollectingFinished ? {} : { filter: `blur(${blurIntensive}px)` }; + const style = showCoffeemaker ? { filter: `blur(${blurIntensive}px)` } : {}; return (
    - - - + + +
    + + + +

    ?

    +
    +
    - {!state.isCollectingFinished && ( + {showCoffeemaker && (
    Making calculations… Please wait a few minutes @@ -555,7 +195,7 @@ class AnalysisAndTrainingPage extends React.Component {
    @@ -568,25 +208,23 @@ class AnalysisAndTrainingPage extends React.Component { />
    - {state.filters.length && ( - - )} +
    - {state.statistic && } +
    @@ -594,40 +232,39 @@ class AnalysisAndTrainingPage extends React.Component { - +
    @@ -637,13 +274,28 @@ class AnalysisAndTrainingPage extends React.Component { } } -const mapStateToProps = () => ({}); +const mapStateToProps = (store: RootStore) => ({ + isIssuesExist: store.common.isIssuesExist, + isLoadedIssuesStatus: store.common.isLoadedIssuesStatus, + statuses: store.analysisAndTraining.statuses, + significantTerms: store.analysisAndTraining.significantTerms, + frequentlyTerms: store.analysisAndTraining.frequentlyTerms, + defectSubmission: store.analysisAndTraining.defectSubmission, + statistic: store.analysisAndTraining.statistic, + filters: store.analysisAndTraining.filters, + totalStatistic: store.analysisAndTraining.totalStatistic, + warnings: store.analysisAndTraining.warnings, +}); const mapDispatchToProps = { addToastWithAction, addToast, - setCollectingDataStatus, removeToastByOuterId, + uploadDashboardData, + uploadSignificantTermsList, + uploadDefectSubmission, + updateFilters, + checkIssuesStatus, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/src/app/pages/auth/auth.page.scss b/frontend/src/app/pages/auth/auth.page.scss index 4d2c5d5..34dcc9b 100644 --- a/frontend/src/app/pages/auth/auth.page.scss +++ b/frontend/src/app/pages/auth/auth.page.scss @@ -2,12 +2,32 @@ $borderRadius: 10px; +@mixin resolutionStyles($resolution, $formWidth, $contentHeight) { + @media screen and (max-width: $resolution) { + .auth-page { + &__slider { + width: calc(100% - #{$formWidth} + #{$borderRadius}); + } + &__main { + width: $formWidth; + } + &__content { + height: $contentHeight; + } + } + } +} + .auth-page { min-height: 700px; height: 100vh; width: 100vw; background-size: cover; + @include resolutionStyles(1920px, 480px, 720px); + @include resolutionStyles(1600px, 410px, 600px); + @include resolutionStyles(1280px, 375px, 520px); + &__container { width: 85%; margin: auto; @@ -16,18 +36,19 @@ $borderRadius: 10px; align-items: flex-start; justify-content: center; height: 100%; - padding-bottom: 100px; - padding-top: 20px; + padding-bottom: 20px; + //padding-bottom: 100px; + //padding-top: 20px; } &__content { margin-top: 40px; width: 100%; - height: 720px; display: flex; align-items: stretch; + flex-shrink: 0; background: $white; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.35); @@ -38,12 +59,10 @@ $borderRadius: 10px; } &__slider { - width: calc(70% + #{$borderRadius}); height: 100%; } &__main { - width: 30%; height: 100%; position: absolute; @@ -55,6 +74,7 @@ $borderRadius: 10px; border-radius: $borderRadius; padding: 40px 30px; + padding-right: 20px; } &__change-mod-button { @@ -65,20 +85,8 @@ $borderRadius: 10px; &__auth-form { margin-top: 63px; - @media (max-width: 1400px) { - margin-top: 20px; + @media (max-width: 1280px) { + margin-top: 36px; } } } - -@media screen and (max-width: 1600px) { - .auth-page__content { - height: 600px !important; - } -} - -@media screen and (max-width: 1280px) { - .auth-page__content { - height: 520px !important; - } -} diff --git a/frontend/src/app/pages/auth/auth.page.tsx b/frontend/src/app/pages/auth/auth.page.tsx index 6dc7a2f..1cf3810 100644 --- a/frontend/src/app/pages/auth/auth.page.tsx +++ b/frontend/src/app/pages/auth/auth.page.tsx @@ -3,7 +3,7 @@ import { Link, Redirect, Route, Switch } from "react-router-dom"; import { RootStore } from "app/common/types/store.types"; import { connect, ConnectedProps } from "react-redux"; -import { getTeamList, userSignIn, userSignUp } from "app/common/store/auth/thunks"; +import { userSignIn, userSignUp } from "app/common/store/auth/thunks"; import Button, { ButtonStyled } from "app/common/components/button/button"; import { IconType } from "app/common/components/icon/icon"; @@ -19,9 +19,6 @@ import authPageBackground from "assets/images/auth-page/auth-page-background.png import { RouterNames } from "app/common/types/router.types"; class AuthPage extends React.PureComponent { - componentDidMount() { - this.props.getTeamList(); - } render() { const { props } = this; @@ -76,7 +73,6 @@ class AuthPage extends React.PureComponent { className="auth-page__auth-form" signUp={props.userSignUp} status={props.status} - teamList={props.teamList} /> @@ -98,13 +94,11 @@ class AuthPage extends React.PureComponent { const mapStateToProps = ({ auth }: RootStore) => ({ status: auth.status, - teamList: auth.teamList, }); const mapDispatchToProps = { userSignIn, userSignUp, - getTeamList, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/src/app/pages/auth/components/forms/forms.scss b/frontend/src/app/pages/auth/components/forms/forms.scss index 4ac07b6..559b3fd 100644 --- a/frontend/src/app/pages/auth/components/forms/forms.scss +++ b/frontend/src/app/pages/auth/components/forms/forms.scss @@ -1,6 +1,19 @@ @import "src/app/styles/_colors.scss"; .auth-form { + + &_type_sign-up &__field:first-of-type { + @media (max-width: 1280px) { + margin-top: 20px; + } + } + + &_type_sign-in &__submit-button { + @media (max-width: 1280px) { + width: 220px; + } + } + &__title { font-weight: bold; font-size: 24px; @@ -18,10 +31,6 @@ &:first-of-type { margin-top: 60px; - - @media (max-width: 1400px) { - margin-top: 20px; - } } } @@ -34,6 +43,7 @@ &__input { width: 100%; + min-width: 0; margin-left: 20px; box-sizing: border-box; diff --git a/frontend/src/app/pages/auth/components/forms/sign-in.tsx b/frontend/src/app/pages/auth/components/forms/sign-in.tsx index 9bb6139..4140992 100644 --- a/frontend/src/app/pages/auth/components/forms/sign-in.tsx +++ b/frontend/src/app/pages/auth/components/forms/sign-in.tsx @@ -67,7 +67,7 @@ class SignIn extends React.Component { return false; } - if (password.length < 6 || !/^[a-zA-Z0-9_.]+$/i.exec(password)) { + if (password.length < 6) { return false; } @@ -85,7 +85,7 @@ class SignIn extends React.Component { return (
    diff --git a/frontend/src/app/pages/auth/components/forms/sign-up.tsx b/frontend/src/app/pages/auth/components/forms/sign-up.tsx index 5468488..3aa8f35 100644 --- a/frontend/src/app/pages/auth/components/forms/sign-up.tsx +++ b/frontend/src/app/pages/auth/components/forms/sign-up.tsx @@ -2,17 +2,16 @@ import React, { ReactElement } from "react"; import cn from "classnames"; import Button from "app/common/components/button/button"; -import Icon, {IconSize, IconType} from "app/common/components/icon/icon"; +import { IconType } from "app/common/components/icon/icon"; import "./forms.scss"; -import { Team, UserSignUp } from "app/common/types/user.types"; +import { UserSignUp } from "app/common/types/user.types"; import { HttpStatus } from "app/common/types/http.types"; interface SignUpProps { className?: string; signUp: (signUpData: UserSignUp) => void; status: HttpStatus; - teamList: Team[]; } interface SignUpState { @@ -30,7 +29,6 @@ class SignUp extends React.Component { form: { isValid: false, value: { - team: null, email: "", name: "", password: "", @@ -39,43 +37,31 @@ class SignUp extends React.Component { }; } - changeField = (e: React.ChangeEvent) => { - const {form} = this.state; - const name: 'email' | 'name' | 'password' = e.target.name as ('email' | 'name' | 'password'); + changeField = (e: React.ChangeEvent) => { + const { form } = this.state; + const name: "email" | "name" | "password" = e.target.name as "email" | "name" | "password"; form.value[name] = e.target.value; this.setState({ form }); }; - changeSelect = (e: React.ChangeEvent) => { - const {form} = this.state; + formValidation = () => { + const { isValid } = this.state.form; - form.value.team = parseInt(e.target.value, 10) > -1 ? parseInt(e.target.value, 10) : null; + if (isValid !== this.checkFormIsValid()) { + this.setFormValidationStatus(!isValid); + } + }; + setFormValidationStatus = (newStatus: boolean) => { + const { form } = this.state; + form.isValid = newStatus; this.setState({ form }); }; - formValidation = () => { - const {isValid} = this.state.form; - - if (isValid !== this.checkFormIsValid()) { - this.setFormValidationStatus(!isValid); - } - }; - - setFormValidationStatus = (newStatus: boolean) => { - const {form} = this.state; - form.isValid = newStatus; - this.setState({ form }); - } - - checkFormIsValid = () => { - const {team, email, name, password} = this.state.form.value; - - if (typeof team !== "number") { - return false; - } + checkFormIsValid = () => { + const { email, name, password } = this.state.form.value; // email contain @ and dot in domain and doesn't contain space symbols if (!/[a-zA-Z0-9_\-.]+@([a-zA-Z0-9_\-.]+\.)+[a-zA-Z0-9_\-.]{1,10}$/i.exec(email)) { @@ -87,7 +73,7 @@ class SignUp extends React.Component { return false; } - if (password.length < 6 || !/^[a-zA-Z0-9_.]+$/i.exec(password)) { + if (password.length < 6) { return false; } @@ -97,40 +83,20 @@ class SignUp extends React.Component { formSubmit = (e: React.FormEvent): void => { e.preventDefault(); - this.props.signUp(this.state.form.value); - }; + this.props.signUp(this.state.form.value); + }; render(): ReactElement { const { state, props } = this; return (

    Register

    - -
    - -

    - {str} -

    -
    -
    + {isTooltipDisplayed ? ( + +

    setIsContentHidden(!isContentHidden)}> + {tdMessage} +

    +
    + ) : ( +

    setIsContentHidden(!isContentHidden)}> + {tdMessage} +

    + )} +