43 Commits

Author SHA1 Message Date
d2bea9180a Adding Signature link, need a destination 2025-05-15 01:52:58 +02:00
f0b241f37d Adding preview du contract drafts 2025-05-15 00:34:48 +02:00
f76f4d5673 Refactoring schema fetching in hooks 2025-05-14 20:44:03 +02:00
72b6f26ebc Minor refacto 2025-05-14 18:27:15 +02:00
b06ce4eefd Correcting typo in foreignkey 2025-05-14 18:26:52 +02:00
49317e905b Handling Variables in form 2025-05-14 18:26:24 +02:00
189c896e60 New columns for Template lists 2025-05-13 21:22:48 +02:00
4ae0b321b9 Correcting foreign key short comings 2025-05-13 21:22:25 +02:00
5a3b87e82c Correcting key position in looping component 2025-05-13 20:31:00 +02:00
0731ac3b6e Implementing enum references for columns and filters 2025-05-13 20:30:34 +02:00
77fa4cde35 Correction on core models and schemas 2025-05-13 16:24:20 +02:00
9aac1d3e34 Minor refactoring of schema provider 2025-05-13 16:22:58 +02:00
7aced9477c Localization provider for muix 2025-05-13 03:37:21 +02:00
5a0327c930 Dynamic Filters 2025-05-13 03:37:06 +02:00
18e4fcea28 Upgrading node packages 2025-05-11 17:04:52 +02:00
239ab2e241 Adding Crud filters form 2025-05-11 17:04:35 +02:00
9fcead8c95 Removing trailing console log 2025-05-11 14:45:16 +02:00
73871ae04e Correcting contract filters 2025-05-11 14:44:40 +02:00
c35c63b421 Adding timestamps filters to all Documents 2025-05-11 14:44:18 +02:00
9fd201c10a WIP trying to correct firm initialization 2025-05-07 17:39:37 +02:00
cb81e233a5 Correcting deprecated call on Menu comp 2025-05-07 14:56:47 +02:00
40648c3fdf Adding a link from Edit to List 2025-05-06 23:08:37 +02:00
6248248f0e Moving list schemas logique to crud lib 2025-05-06 22:14:41 +02:00
7bbd607376 Stop redirecting to login 2025-05-06 21:23:06 +02:00
4f5d5425fc Centralizing data provider error management 2025-05-06 21:19:32 +02:00
d48edbbf5f New way of handling firm initialization 2025-05-06 21:18:07 +02:00
0d337849c7 Updating column size 2025-05-06 21:16:52 +02:00
717a0ed830 Catching http error 400 whatever their formats 2025-05-06 21:16:26 +02:00
990e7fa226 Throwing 404 to the wall 2025-05-06 21:15:02 +02:00
5a8050145d Adding the 25 option to list size 2025-05-06 21:11:36 +02:00
1cc6e1e85d Adding types au List Schemas 2025-05-06 21:10:37 +02:00
765c0749bb Updating oauth validation redirect path 2025-05-06 21:07:57 +02:00
5080e5fdde Changing the order of firm and user validation 2025-05-06 21:07:25 +02:00
2fed7fa4e7 Dynamics list columns with a lot of work ahead 2025-05-06 00:58:47 +02:00
0613efa846 Correcting label update on updating 2025-05-04 17:30:28 +02:00
c8466c557d Adding filter on Entity type 2025-05-04 17:30:08 +02:00
0d7cad945c Handling order by by determinant fields 2025-05-04 16:26:53 +02:00
ea5093f2c2 Handling mongo indexes in firm 2025-05-04 02:26:06 +02:00
b542fd40a6 Renaming Collection CurrentFirmModel to CurrentFirm 2025-05-04 00:26:22 +02:00
a9e9f97c14 Phasing out Pydantic v1's class Config 2025-05-03 22:28:41 +02:00
90a46ada2d Finishing ForeignKey Migration and handling of their None values 2025-05-03 21:51:56 +02:00
4f0d943e04 Updating Foreign key to new CrudForm standard 2025-05-03 21:24:39 +02:00
04ff66f187 Improving display of numbered array fields 2025-05-03 21:05:35 +02:00
50 changed files with 2346 additions and 1146 deletions

View File

@@ -4,10 +4,11 @@ from enum import Enum
from uuid import UUID from uuid import UUID
from beanie import PydanticObjectId from beanie import PydanticObjectId
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry, ForeignKey from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry, ForeignKey, \
CrudDocumentConfig
from firm.core.filter import Filter, FilterSchema from firm.core.filter import Filter, FilterSchema
from firm.entity.models import Entity from firm.entity.models import Entity
@@ -27,13 +28,13 @@ class ContractDraftStatus(str, Enum):
class DraftParty(BaseModel): class DraftParty(BaseModel):
entity_id: PydanticObjectId = ForeignKey("entities", "Entity", default="", title="Partie") model_config = ConfigDict(title='Partie')
entity: SkipJsonSchema[Entity] = Field(default=None, exclude=True, )
part: str = Field(title="Rôle")
representative_id: PydanticObjectId = ForeignKey("entities", "Entity", default="", title="Représentant")
class Config: entity_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Partie")
title = 'Partie' part: str = Field(title="Rôle")
representative_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Représentant")
entity: SkipJsonSchema[Entity] = Field(default=None, exclude=True, )
class Party(BaseModel): class Party(BaseModel):
@@ -49,34 +50,31 @@ class ContractProvisionType(Enum):
template = 'template' template = 'template'
class ProvisionGenuine(BaseModel): class ProvisionGenuine(BaseModel):
model_config = ConfigDict(title='Clause personalisée')
type: Literal['genuine'] = ContractProvisionType.genuine type: Literal['genuine'] = ContractProvisionType.genuine
title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre") title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre")
body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps") body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps")
class Config:
title = 'Clause personalisée'
class ContractProvisionTemplateReference(BaseModel): class ContractProvisionTemplateReference(BaseModel):
model_config = ConfigDict(title='Template de clause')
type: Literal['template'] = ContractProvisionType.template type: Literal['template'] = ContractProvisionType.template
provision_template_id: PydanticObjectId = ForeignKey( provision_template_id: PydanticObjectId = ForeignKey(
"templates/provisions", "templates/provisions",
"ProvisionTemplate", "ProvisionTemplate",
displayed_fields=['title', 'body'], displayed_fields=['title', 'body'],
props={"parametrized": True}, props={"parametrized": True},
default="", default=None,
title="Template de clause" title="Template de clause"
) )
class Config:
title = 'Template de clause'
class DraftProvision(BaseModel): class DraftProvision(BaseModel):
provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type') model_config = ConfigDict(title='Clause')
class Config: provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type')
title = 'Clause'
class Provision(BaseModel): class Provision(BaseModel):
@@ -91,6 +89,7 @@ class ContractDraft(CrudDocument):
""" """
Brouillon de contrat à remplir Brouillon de contrat à remplir
""" """
model_config = ConfigDict(title='Brouillon de contrat')
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = Field(title="Titre") title: str = Field(title="Titre")
@@ -100,18 +99,6 @@ class ContractDraft(CrudDocument):
status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut") status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut")
todo: List[str] = Field(default=[], title="Reste à faire") todo: List[str] = Field(default=[], title="Reste à faire")
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title']
bson_encoders = {
datetime.date: lambda dt: dt if hasattr(dt, 'hour')
else datetime.datetime(year=dt.year, month=dt.month, day=dt.day,
hour=0, minute=0, second=0)
}
class Config:
title = 'Brouillon de contrat'
async def check_is_ready(self, db): async def check_is_ready(self, db):
if self.status == ContractDraftStatus.published: if self.status == ContractDraftStatus.published:
return return
@@ -155,6 +142,11 @@ class Contract(CrudDocument):
Contrat publié. Les contrats ne peuvent pas être modifiés. Contrat publié. Les contrats ne peuvent pas être modifiés.
Ils peuvent seulement être signés par les parties et imprimés par l'avocat Ils peuvent seulement être signés par les parties et imprimés par l'avocat
""" """
model_config = ConfigDict(title='Contrat')
document_config = CrudDocumentConfig(
indexes=["parties.signature_uuid"],
)
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = Field(title="Titre") title: str = Field(title="Titre")
parties: List[Party] = Field(title="Parties") parties: List[Party] = Field(title="Parties")
@@ -175,15 +167,6 @@ class Contract(CrudDocument):
contract_label = f"{contract_label} - {self.date.strftime('%m/%d/%Y')}" contract_label = f"{contract_label} - {self.date.strftime('%m/%d/%Y')}"
return contract_label return contract_label
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title']
bson_encoders = {
datetime.date: lambda dt: dt if hasattr(dt, 'hour')
else datetime.datetime(year=dt.year, month=dt.month, day=dt.day,
hour=0, minute=0, second=0)
}
@classmethod @classmethod
async def find_by_signature_id(cls, db, signature_id: UUID): async def find_by_signature_id(cls, db, signature_id: UUID):
request = {'parties': {"$elemMatch": {"signature_uuid": str(signature_id) }}} request = {'parties': {"$elemMatch": {"signature_uuid": str(signature_id) }}}
@@ -227,15 +210,15 @@ def replace_variables_in_value(variables, value: str):
class ContractDraftFilters(FilterSchema): class ContractDraftFilters(FilterSchema):
status: Optional[str] = None status__in: Optional[list[str]] = None
class Constants(Filter.Constants): class Constants(Filter.Constants):
model = ContractDraft model = ContractDraft
search_model_fields = ["label", "status"] search_model_fields = ["label"]
class ContractFilters(FilterSchema): class ContractFilters(FilterSchema):
status: Optional[str] = None status__in: Optional[list[str]] = None
class Constants(Filter.Constants): class Constants(Filter.Constants):
model = Contract model = Contract
search_model_fields = ["label", "status"] search_model_fields = ["label"]

View File

@@ -3,6 +3,7 @@ import os
import base64 import base64
from uuid import UUID from uuid import UUID
from beanie import PydanticObjectId
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -18,24 +19,22 @@ from firm.template.models import ProvisionTemplate
from firm.contract.models import ContractDraft, Contract, ContractStatus, replace_variables_in_value from firm.contract.models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
async def build_model(model): async def build_model(db, model):
parties = [] parties = []
for p in model.parties: for p in model.parties:
party = { party = {
"entity": await Entity.get(p.entity_id), "entity": await Entity.get(db, p.entity_id),
"part": p.part "part": p.part
} }
if p.representative_id: if p.representative_id:
party['representative'] = await Entity.get(p.representative_id) party['representative'] = await Entity.get(db, p.representative_id)
parties.append(party) parties.append(party)
model.parties = parties
provisions = [] provisions = []
for p in model.provisions: for p in model.provisions:
if p.provision.type == "template": if p.provision.type == "template":
provision = await ProvisionTemplate.get(p.provision.provision_template_id) provision = await ProvisionTemplate.get(db, p.provision.provision_template_id)
else: else:
provision = p.provision provision = p.provision
@@ -43,16 +42,16 @@ async def build_model(model):
provision.body = replace_variables_in_value(model.variables, provision.body) provision.body = replace_variables_in_value(model.variables, provision.body)
provisions.append(provision) provisions.append(provision)
model.provisions = provisions model_dict = model.dict()
model_dict['parties'] = parties
model = model.dict() model_dict['provisions'] = provisions
model['location'] = "Los Santos, SA" model_dict['location'] = "Los Santos, SA"
model['date'] = datetime.date(1970, 1, 1) model_dict['date'] = datetime.date(1970, 1, 1)
model['lawyer'] = {'entity_data': { model_dict['lawyer'] = {'entity_data': {
"firstname": "prénom avocat", "firstname": "prénom avocat",
"lastname": "nom avocat", "lastname": "nom avocat",
}} }}
return model return model_dict
BASE_PATH = Path(__file__).resolve().parent BASE_PATH = Path(__file__).resolve().parent
@@ -87,8 +86,14 @@ def retrieve_signature_png(filepath):
preview_router = APIRouter() preview_router = APIRouter()
@preview_router.get("/draft/{draft_id}", response_class=HTMLResponse, tags=["Contract Draft"]) @preview_router.get("/draft/{draft_id}", response_class=HTMLResponse, tags=["Contract Draft"])
async def preview_draft(draft_id: str, reg=Depends(get_tenant_registry)) -> str: async def preview_draft(draft_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
draft = await build_model(await ContractDraft.get(reg.db, draft_id)) record = await ContractDraft.get(reg.db, draft_id)
if not record:
raise HTTPException(
status_code=404,
detail=f"Contract Draft record not found!"
)
draft = await build_model(reg.db, record)
return await render_print('', draft) return await render_print('', draft)
@@ -104,7 +109,7 @@ async def preview_contract_by_signature(signature_id: UUID, reg=Depends(get_tena
@preview_router.get("/{contract_id}", response_class=HTMLResponse, tags=["Contract"]) @preview_router.get("/{contract_id}", response_class=HTMLResponse, tags=["Contract"])
async def preview_contract(contract_id: str, reg=Depends(get_tenant_registry)) -> str: async def preview_contract(contract_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.get(reg.db, contract_id) contract = await Contract.get(reg.db, contract_id)
for p in contract.parties: for p in contract.parties:
if p.signature_affixed: if p.signature_affixed:
@@ -115,7 +120,7 @@ async def preview_contract(contract_id: str, reg=Depends(get_tenant_registry)) -
print_router = APIRouter() print_router = APIRouter()
@print_router.get("/pdf/{contract_id}", response_class=FileResponse, tags=["Contract"]) @print_router.get("/pdf/{contract_id}", response_class=FileResponse, tags=["Contract"])
async def create_pdf(contract_id: str, reg=Depends(get_tenant_registry)) -> str: async def create_pdf(contract_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.get(reg.db, contract_id) contract = await Contract.get(reg.db, contract_id)
contract_path = "media/contracts/{}.pdf".format(contract_id) contract_path = "media/contracts/{}.pdf".format(contract_id)
if not os.path.isfile(contract_path): if not os.path.isfile(contract_path):
@@ -142,7 +147,7 @@ async def create_pdf(contract_id: str, reg=Depends(get_tenant_registry)) -> str:
@print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse, tags=["Signature"]) @print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse, tags=["Signature"])
async def get_signature_opengraph(signature_id: str, request: Request, reg=Depends(get_tenant_registry)) -> str: async def get_signature_opengraph(signature_id: UUID, request: Request, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.find_by_signature_id(reg.db, signature_id) contract = await Contract.find_by_signature_id(reg.db, signature_id)
signature = contract.get_signature(signature_id) signature = contract.get_signature(signature_id)
template = templates.get_template("opengraph.html") template = templates.get_template("opengraph.html")

View File

@@ -57,7 +57,7 @@
<div class="footer"> <div class="footer">
<hr/> <hr/>
<p>À {{ contract.location }} le {{ contract.date.strftime('%d/%m/%Y') }}</p> <p>À {{ contract.location }} le {{ contract.date.strftime('%d/%m/%Y') }}</p>
<p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p> <p class="mention">(Signatures précédées de la mention « Lu et approuvé »)</p>
<table class="signatures"> <table class="signatures">
<tr> <tr>
{% for party in contract.parties %} {% for party in contract.parties %}

View File

@@ -2,7 +2,7 @@ import datetime
from typing import List from typing import List
from beanie import PydanticObjectId from beanie import PydanticObjectId
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from firm.contract.models import ContractDraft, DraftProvision, DraftParty, Contract from firm.contract.models import ContractDraft, DraftProvision, DraftParty, Contract
@@ -42,32 +42,29 @@ class ContractDraftUpdate(ContractDraftCreate):
class ForeignEntityRead(BaseModel): class ForeignEntityRead(BaseModel):
label: str model_config = ConfigDict(title='Avocat')
class Config: label: str
title = "Avocat"
class PartyRead(BaseModel): class PartyRead(BaseModel):
model_config = ConfigDict(title='Partie')
signature_affixed: bool = Field(title='Signature apposée?') signature_affixed: bool = Field(title='Signature apposée?')
signature_uuid: str = Field(props={"display": "signature-link"}, title="Lien vers signature") signature_uuid: str = Field(props={"display": "signature-link"}, title="Lien vers signature")
part: str = Field(title='Rôle') part: str = Field(title='Rôle')
entity: ForeignEntityRead = Field(title='Client') entity: ForeignEntityRead = Field(title='Client')
class Config:
title = "Partie"
class ContractRead(Reader, Contract): class ContractRead(Reader, Contract):
model_config = ConfigDict(title='Contrat')
parties: List[PartyRead] = Field( parties: List[PartyRead] = Field(
props={"items_per_row": "2"}, props={"items_per_row": "2"},
title='Parties' title='Parties'
) )
lawyer: ForeignEntityRead lawyer: ForeignEntityRead
class Config:
title = "Contrat"
class ContractCreate(Writer): class ContractCreate(Writer):
date: datetime.date date: datetime.date

View File

@@ -2,7 +2,7 @@ from fastapi import HTTPException, Depends
from hub.auth import get_current_user from hub.auth import get_current_user
from firm.current_firm import CurrentFirmModel, Partner from firm.current_firm import CurrentFirm, Partner
from firm.db import get_db_client from firm.db import get_db_client
from firm.entity.models import Entity from firm.entity.models import Entity
@@ -16,43 +16,55 @@ class Registry:
self.instance = instance self.instance = instance
self.firm = firm self.firm = firm
self.current_firm = CurrentFirmModel.get_current(self.db) self.current_firm = CurrentFirm.get_current(self.db)
async def set_user(self, user): def check_user(self, user):
for firm in user.firms: for firm in user.firms:
if firm.instance == self.instance and firm.firm == self.firm: if firm.instance == self.instance and firm.firm == self.firm:
partner = await Partner.get_by_user_id(self.db, user.id) return True
partner_entity = await Entity.get(self.db, partner.entity_id) raise PermissionError
self.user = user
self.partner = partner_entity async def set_user(self, user):
self.db.partner = partner_entity self.check_user(user)
return
partner = await Partner.get_by_user_id(self.db, user.id)
partner_entity = await Entity.get(self.db, partner.entity_id)
self.user = user
self.partner = partner_entity
self.db.partner = partner_entity
return
raise PermissionError raise PermissionError
async def get_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client)) -> Registry: async def get_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client)) -> Registry:
registry = Registry(db_client, instance, firm) registry = Registry(db_client, instance, firm)
if await registry.current_firm is None: if await registry.current_firm is None:
raise HTTPException(status_code=405, detail=f"Firm needs to be initialized first") raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
return registry return registry
async def get_authed_tenant_registry(registry=Depends(get_tenant_registry), user=Depends(get_current_user)) -> Registry: async def get_authed_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client), user=Depends(get_current_user)) -> Registry:
registry = Registry(db_client, instance, firm)
try: try:
await registry.set_user(user) registry.check_user(user)
except PermissionError: except PermissionError:
raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.") raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
if await registry.current_firm is None:
raise HTTPException(status_code=405, detail=f"Firm needs to be initialized first")
await registry.set_user(user)
return registry return registry
async def get_uninitialized_registry(instance: str, firm: str, db_client=Depends(get_db_client), user=Depends(get_current_user)) -> Registry: async def get_uninitialized_registry(instance: str, firm: str, db_client=Depends(get_db_client), user=Depends(get_current_user)) -> Registry:
registry = Registry(db_client, instance, firm) registry = Registry(db_client, instance, firm)
if await registry.current_firm is not None:
raise HTTPException(status_code=409, detail="Firm configuration already exists")
try: try:
await registry.set_user(user) registry.check_user(user)
except PermissionError: except PermissionError:
raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.") raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
if await registry.current_firm is not None:
raise HTTPException(status_code=409, detail="Firm configuration already exists")
await registry.set_user(user)
return registry return registry

View File

@@ -1,7 +1,7 @@
from collections import defaultdict
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import Any, Optional, Union
from pydantic import ValidationInfo, field_validator from pydantic import ValidationInfo, field_validator
from typing import Any, Optional, Union
from fastapi_filter.base.filter import BaseFilterModel from fastapi_filter.base.filter import BaseFilterModel
@@ -24,28 +24,6 @@ _odm_operator_transformer: dict[str, Callable[[Optional[str]], Optional[dict[str
class Filter(BaseFilterModel): class Filter(BaseFilterModel):
"""Base filter for beanie related filters.
Example:
```python
class MyModel:
id: PrimaryKey()
name: StringField(null=True)
count: IntField()
created_at: DatetimeField()
class MyModelFilter(Filter):
id: Optional[int]
id__in: Optional[str]
count: Optional[int]
count__lte: Optional[int]
created_at__gt: Optional[datetime]
name__ne: Optional[str]
name__nin: Optional[list[str]]
name__isnull: Optional[bool]
```
"""
def sort(self): def sort(self):
if not self.ordering_values: if not self.ordering_values:
return None return None
@@ -130,6 +108,58 @@ class Filter(BaseFilterModel):
query[field_name] = value query[field_name] = value
return query return query
@staticmethod
def field_exists(model, field_path: str) -> bool:
if "." in field_path:
[root, field] = field_path.split(".", 1)
return hasattr(model, "model_fields") and root in model.model_fields \
and model.model_fields[root].discriminator == field
return hasattr(model, field_path) or (hasattr(model, "model_fields") and field_path in model.model_fields)
@field_validator("*", mode="before", check_fields=False)
def validate_order_by(cls, value, field: ValidationInfo):
if field.field_name != cls.Constants.ordering_field_name:
return value
if not value:
return None
field_name_usages = defaultdict(list)
duplicated_field_names = set()
for field_name_with_direction in value:
field_name = field_name_with_direction.replace("-", "").replace("+", "")
if not cls.field_exists(cls.Constants.model, field_name):
raise ValueError(f"{field_name} is not a valid ordering field.")
field_name_usages[field_name].append(field_name_with_direction)
if len(field_name_usages[field_name]) > 1:
duplicated_field_names.add(field_name)
if duplicated_field_names:
ambiguous_field_names = ", ".join(
[
field_name_with_direction
for field_name in sorted(duplicated_field_names)
for field_name_with_direction in field_name_usages[field_name]
]
)
raise ValueError(
f"Field names can appear at most once for {cls.Constants.ordering_field_name}. "
f"The following was ambiguous: {ambiguous_field_names}."
)
return value
class FilterSchema(Filter): class FilterSchema(Filter):
label__ilike: Optional[str] = None label__ilike: Optional[str] = None
search: Optional[str] = None
order_by: Optional[list[str]] = None order_by: Optional[list[str]] = None
created_at__lte: Optional[str] = None
created_at__gte: Optional[str] = None
created_by__in: Optional[list[str]] = None
updated_at__lte: Optional[str] = None
updated_at__gte: Optional[str] = None
updated_by__in: Optional[list[str]] = None

View File

@@ -1,12 +1,18 @@
from datetime import datetime, UTC from datetime import datetime, UTC
from typing import Optional from typing import Optional, TypedDict, ClassVar
from beanie import PydanticObjectId from beanie import PydanticObjectId
from motor.motor_asyncio import AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import BaseModel, Field, computed_field from pydantic import BaseModel, Field, computed_field
class CrudDocumentConfig(TypedDict, total=False):
fulltext_search: list[str]
indexes: list[str]
class CrudDocument(BaseModel): class CrudDocument(BaseModel):
document_config: ClassVar[CrudDocumentConfig] = CrudDocumentConfig()
id: Optional[PydanticObjectId] = Field(default=None) id: Optional[PydanticObjectId] = Field(default=None)
created_at: datetime = Field(default=datetime.now(UTC), nullable=False, title="Créé le") created_at: datetime = Field(default=datetime.now(UTC), nullable=False, title="Créé le")
created_by: Optional[PydanticObjectId] = Field(default=None, title="Créé par") created_by: Optional[PydanticObjectId] = Field(default=None, title="Créé par")
@@ -17,16 +23,13 @@ class CrudDocument(BaseModel):
def _id(self): def _id(self):
return self.id return self.id
@computed_field @computed_field(title="Label")
def label(self) -> str: def label(self) -> str:
return self.compute_label() return self.compute_label()
def compute_label(self) -> str: def compute_label(self) -> str:
return "" return ""
class Settings:
fulltext_search = []
@classmethod @classmethod
def _collection_name(cls): def _collection_name(cls):
return cls.__name__ return cls.__name__
@@ -35,6 +38,10 @@ class CrudDocument(BaseModel):
def _get_collection(cls, db) -> AsyncIOMotorCollection: def _get_collection(cls, db) -> AsyncIOMotorCollection:
return db.get_collection(cls._collection_name()) return db.get_collection(cls._collection_name())
@classmethod
def create_index(cls, db, index):
cls._get_collection(db).create_index(index)
@classmethod @classmethod
async def create(cls, db, create_schema): async def create(cls, db, create_schema):
model_dict = create_schema.model_dump() | {"created_by": db.partner.id, "updated_by": db.partner.id} model_dict = create_schema.model_dump() | {"created_by": db.partner.id, "updated_by": db.partner.id}
@@ -71,20 +78,22 @@ class CrudDocument(BaseModel):
@classmethod @classmethod
async def update(cls, db, model, update_schema): async def update(cls, db, model, update_schema):
model_dict = update_schema.model_dump(mode="json") | {"updated_by": db.partner.id} model_dict = update_schema.model_dump(mode="json") | {"updated_by": db.partner.id, "updated_at": datetime.now(UTC)}
update_query = { update_query = {
"$set": {field: value for field, value in model_dict.items() if field!= "id" } "$set": {field: value for field, value in model_dict.items() if field!= "id" }
} }
await cls._get_collection(db).update_one({"_id": model.id}, update_query) await cls._get_collection(db).update_one({"_id": model.id}, update_query)
return await cls.get(db, model.id) new_model = await cls.get(db, model.id)
if new_model.label != model.label:
await cls._get_collection(db).update_one({"_id": model.id}, {"$set": {"label": new_model.label}})
return new_model
@classmethod @classmethod
async def delete(cls, db, model): async def delete(cls, db, model):
await cls._get_collection(db).delete_one({"_id": model.id}) await cls._get_collection(db).delete_one({"_id": model.id})
def text_area(*args, **kwargs): def text_area(*args, **kwargs):
kwargs['widget'] = { kwargs['widget'] = {
"formlyConfig": { "formlyConfig": {

View File

@@ -5,7 +5,9 @@ from pydantic import BaseModel, Field
class Reader(BaseModel): class Reader(BaseModel):
id: Optional[PydanticObjectId] = Field(default=None, validation_alias="_id") id: Optional[PydanticObjectId] = Field(validation_alias="_id")
created_by: PydanticObjectId = Field(title="Créé par")
updated_by: PydanticObjectId = Field(title="Modifié par")
@classmethod @classmethod
def from_model(cls, model): def from_model(cls, model):

View File

@@ -1,14 +1,13 @@
from typing import Any from typing import Any, Optional
from beanie import PydanticObjectId from beanie import PydanticObjectId
from pydantic import Field from pydantic import Field, BaseModel
from firm.core.models import CrudDocument from firm.core.models import CrudDocument, CrudDocumentConfig
from firm.core.schemas import Writer, Reader from firm.core.schemas import Writer, Reader
from firm.entity.schemas import EntityIndividualCreate, EntityCorporationCreate, EntityRead from firm.entity.schemas import EntityIndividualCreate, EntityCorporationCreate, EntityRead
class CurrentFirm(CrudDocument):
class CurrentFirmModel(CrudDocument):
instance: str = Field() instance: str = Field()
firm: str = Field() firm: str = Field()
entity_id: PydanticObjectId = Field() entity_id: PydanticObjectId = Field()
@@ -33,7 +32,8 @@ class CurrentFirmModel(CrudDocument):
return cls.model_validate(document) return cls.model_validate(document)
class CurrentFirmSchemaRead(Reader): class CurrentFirmSchemaRead(BaseModel):
id: Optional[PydanticObjectId]
entity: EntityRead entity: EntityRead
partner: EntityRead partner: EntityRead
partner_list: list[EntityRead] partner_list: list[EntityRead]
@@ -60,6 +60,10 @@ class CurrentFirmSchemaUpdate(Writer):
pass pass
class Partner(CrudDocument): class Partner(CrudDocument):
document_config = CrudDocumentConfig(
indexes=["user_id", "entity_id"],
)
user_id: PydanticObjectId = Field() user_id: PydanticObjectId = Field()
entity_id: PydanticObjectId = Field() entity_id: PydanticObjectId = Field()

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from firm.core.depends import get_authed_tenant_registry, get_uninitialized_registry from firm.core.depends import get_authed_tenant_registry, get_uninitialized_registry
from firm.current_firm import CurrentFirmModel, CurrentFirmSchemaRead, CurrentFirmSchemaCreate, CurrentFirmSchemaUpdate, Partner from firm.current_firm import CurrentFirm, CurrentFirmSchemaRead, CurrentFirmSchemaCreate, CurrentFirmSchemaUpdate, Partner
from firm.entity.models import Entity, Employee from firm.entity.models import Entity, Employee
from firm.entity.schemas import EntityRead from firm.entity.schemas import EntityRead
@@ -9,7 +9,7 @@ current_firm_router = APIRouter()
@current_firm_router.get("/", response_model=CurrentFirmSchemaRead, response_description=f"Current Firm records retrieved") @current_firm_router.get("/", response_model=CurrentFirmSchemaRead, response_description=f"Current Firm records retrieved")
async def read(reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead: async def read(reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead:
document = await CurrentFirmModel.get_current(reg.db) document = await CurrentFirm.get_current(reg.db)
firm_entity = await Entity.get(reg.db, document.entity_id) firm_entity = await Entity.get(reg.db, document.entity_id)
partner = await Partner.get_by_user_id(reg.db, reg.user.id) partner = await Partner.get_by_user_id(reg.db, reg.user.id)
partner = await Entity.get(reg.db, partner.entity_id) partner = await Entity.get(reg.db, partner.entity_id)
@@ -31,17 +31,25 @@ async def create(schema: CurrentFirmSchemaCreate, reg=Depends(get_uninitialized_
corporation_schema.entity_data.employees.append(Employee(entity_id=owner_entity.id, position=schema.position)) corporation_schema.entity_data.employees.append(Employee(entity_id=owner_entity.id, position=schema.position))
corp = await Entity.create(reg.db, corporation_schema) corp = await Entity.create(reg.db, corporation_schema)
document = await CurrentFirmModel.create(reg.db, CurrentFirmModel( firm = await CurrentFirm.create(reg.db, CurrentFirm(
instance=reg.instance, instance=reg.instance,
firm=reg.firm, firm=reg.firm,
entity_id=corp.id, entity_id=corp.id,
primary_color=schema.primary_color, primary_color=schema.primary_color,
secondary_color=schema.secondary_color, secondary_color=schema.secondary_color,
)) ))
return CurrentFirmSchemaRead.from_model_and_entities(document, EntityRead.from_model(corp), EntityRead.from_model(owner_entity))
await Partner.create(Partner(user_id=reg.user.id, entity_id=owner_entity.id))
return CurrentFirmSchemaRead.from_model_and_entities(
firm,
EntityRead.from_model(corp),
EntityRead.from_model(owner_entity),
[EntityRead.from_model(owner_entity)]
)
@current_firm_router.put("/", response_description=f"Current Firm record updated") @current_firm_router.put("/", response_description=f"Current Firm record updated")
async def update(schema: CurrentFirmSchemaUpdate, reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead: async def update(schema: CurrentFirmSchemaUpdate, reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead:
document = await CurrentFirmModel.get_current(reg.db) document = await CurrentFirm.get_current(reg.db)
document = await CurrentFirmModel.update(reg.db, document, schema) document = await CurrentFirm.update(reg.db, document, schema)
return CurrentFirmSchemaRead.from_model(document) return CurrentFirmSchemaRead.from_model(document)

View File

@@ -11,9 +11,6 @@ client = motor.motor_asyncio.AsyncIOMotorClient(
DATABASE_URL, uuidRepresentation="standard" DATABASE_URL, uuidRepresentation="standard"
) )
async def init_db():
pass
async def stop_db(): async def stop_db():
client.close() client.close()

View File

@@ -1,10 +1,12 @@
from datetime import date, datetime from datetime import date
from enum import Enum
from typing import List, Literal, Optional from typing import List, Literal, Optional
from pydantic import Field, BaseModel from fastapi_filter import FilterDepends, with_prefix
from beanie import Indexed, PydanticObjectId from pydantic import Field, BaseModel, ConfigDict
from beanie import PydanticObjectId
from firm.core.models import CrudDocument from firm.core.models import CrudDocument, ForeignKey, CrudDocumentConfig
from firm.core.filter import Filter, FilterSchema from firm.core.filter import Filter, FilterSchema
@@ -13,13 +15,19 @@ class EntityType(BaseModel):
def label(self) -> str: def label(self) -> str:
return self.title return self.title
class EntityTypeEnum(str, Enum):
individual = 'individual'
corporation = 'corporation'
institution = 'institution'
class Individual(EntityType): class Individual(EntityType):
model_config = ConfigDict(title='Particulier')
type: Literal['individual'] = 'individual' type: Literal['individual'] = 'individual'
firstname: Indexed(str) = Field(title='Prénom') firstname: str = Field(title='Prénom')
middlename: Indexed(str) = Field(default="", title='Autres prénoms') middlename: str = Field(default="", title='Autres prénoms')
lastname: Indexed(str) = Field(title='Nom de famille') lastname: str = Field(title='Nom de famille')
surnames: List[Indexed(str)] = Field( surnames: List[str] = Field(
default=[], default=[],
props={"items_per_row": "4", "numbered": True}, props={"items_per_row": "4", "numbered": True},
title="Surnoms" title="Surnoms"
@@ -30,51 +38,41 @@ class Individual(EntityType):
@property @property
def label(self) -> str: def label(self) -> str:
# if len(self.surnames) > 0: # if len(self.surnames) > 0:
# return '{} "{}" {}'.format(self.firstname, self.surnames[0], self.lastname) # return f"{self.firstname} \"{self.surnames[0]}\" {self.lastname}"
return f"{self.firstname} {self.lastname}" return f"{self.firstname} {self.lastname}"
class Config:
title = 'Particulier'
class Employee(BaseModel): class Employee(BaseModel):
position: Indexed(str) = Field(title='Poste') model_config = ConfigDict(title='Fiche Employé')
entity_id: PydanticObjectId = Field(
foreignKey={
"reference": {
"resource": "entities",
"schema": "Entity",
"condition": "entity_data.type=individual"
}
},
title='Employé'
)
class Config: position: str = Field(title='Poste')
title = 'Fiche Employé' entity_id: PydanticObjectId = ForeignKey("entities", "Entity", title='Employé')
class Corporation(EntityType): class Corporation(EntityType):
type: Literal['corporation'] = 'corporation' model_config = ConfigDict(title='Entreprise')
title: Indexed(str) = Field(title='Dénomination sociale')
activity: Indexed(str) = Field(title='Activité')
employees: List[Employee] = Field(default=[], title='Employés')
class Config: type: Literal['corporation'] = 'corporation'
title = 'Entreprise' title: str = Field(title='Dénomination sociale')
activity: str = Field(title='Activité')
employees: List[Employee] = Field(default=[], title='Employés')
class Institution(Corporation): class Institution(Corporation):
type: Literal['institution'] = 'institution' model_config = ConfigDict(title='Institution')
class Config: type: Literal['institution'] = 'institution'
title = 'Institution'
class Entity(CrudDocument): class Entity(CrudDocument):
""" """
Fiche d'un client Fiche d'un client
""" """
model_config = ConfigDict(title='Client')
document_config = CrudDocumentConfig(
indexes=["entity_data.type"],
)
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: str = Field(default="", title='Adresse') address: str = Field(default="", title='Adresse')
@@ -83,20 +81,17 @@ class Entity(CrudDocument):
return "" return ""
return self.entity_data.label return self.entity_data.label
class Settings(CrudDocument.Settings):
fulltext_search = ['label']
bson_encoders = { class EntityDataFilter(Filter):
date: lambda dt: dt if hasattr(dt, 'hour') type__in: Optional[list[str]] = None
else datetime(year=dt.year, month=dt.month, day=dt.day,
hour=0, minute=0, second=0)
}
class Config: class Constants(Filter.Constants):
title = 'Client' model = EntityType
class EntityFilters(FilterSchema): class EntityFilters(FilterSchema):
entity_data: Optional[EntityDataFilter] = FilterDepends(with_prefix("entity_data", EntityDataFilter))
class Constants(Filter.Constants): class Constants(Filter.Constants):
model = Entity model = Entity
search_model_fields = ["label"] search_model_fields = ["label"]

View File

@@ -1,4 +1,4 @@
from pydantic import Field from pydantic import Field, ConfigDict
from firm.entity.models import Entity, Institution, Individual, Corporation from firm.entity.models import Entity, Institution, Individual, Corporation
from firm.core.schemas import Writer, Reader from firm.core.schemas import Writer, Reader
@@ -7,12 +7,11 @@ class EntityRead(Reader, Entity):
pass pass
class EntityCreate(Writer): class EntityCreate(Writer):
model_config = ConfigDict(title="Création d'un client")
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: str = Field(default="", title='Adresse') address: str = Field(default="", title='Adresse')
class Config:
title = "Création d'un client"
class EntityIndividualCreate(EntityCreate): class EntityIndividualCreate(EntityCreate):
entity_data: Individual entity_data: Individual

View File

@@ -0,0 +1,31 @@
import logging
from firm.contract.models import Contract, ContractDraft
from firm.core.depends import Registry
from firm.current_firm import CurrentFirm, Partner
from firm.db import client
from firm.entity.models import Entity
from firm.template.models import ContractTemplate, ProvisionTemplate
from hub.firm import Firm
collections = [CurrentFirm, Entity, Partner, Contract, ContractDraft, ContractTemplate, ProvisionTemplate]
logger = logging.getLogger('uvicorn.error')
async def create_documents_indexes(db):
for collection in collections:
if "indexes" in collection.document_config:
for field in collection.document_config["indexes"]:
collection.create_index(db, field)
async def init_all_db():
logger.info("[FRM] Creating index for firms")
for firm in await Firm.find({}).to_list():
reg = Registry(client, firm.instance, firm.firm)
await reg.current_firm
await create_documents_indexes(reg.db)
async def init_db():
await init_all_db()

View File

@@ -1,38 +1,19 @@
from typing import List from typing import List, Optional
from html import unescape from html import unescape
from beanie import PydanticObjectId from beanie import PydanticObjectId
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from firm.core.models import CrudDocument, RichtextMultiline, RichtextSingleline, DictionaryEntry from firm.core.models import CrudDocument, RichtextMultiline, RichtextSingleline, DictionaryEntry, ForeignKey
from firm.core.filter import Filter, FilterSchema from firm.core.filter import Filter, FilterSchema
class PartyTemplate(BaseModel): class PartyTemplate(BaseModel):
entity_id: PydanticObjectId = Field( model_config = ConfigDict(title="Partie")
foreignKey={
"reference": {
"resource": "entities",
"schema": "Entity",
}
},
default="",
title="Partie"
)
part: str = Field(title="Rôle")
representative_id: PydanticObjectId = Field(
foreignKey={
"reference": {
"resource": "entities",
"schema": "Entity",
}
},
default="",
title="Représentant"
)
class Config: entity_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Partie")
title = 'Partie' part: str = Field(title="Rôle")
representative_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Représentant")
def remove_html_tags(text): def remove_html_tags(text):
@@ -46,6 +27,7 @@ class ProvisionTemplate(CrudDocument):
""" """
Modèle de clause à décliner Modèle de clause à décliner
""" """
model_config = ConfigDict(title="Template de clause")
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = RichtextSingleline(title="Titre") title: str = RichtextSingleline(title="Titre")
@@ -54,34 +36,25 @@ class ProvisionTemplate(CrudDocument):
def compute_label(self) -> str: def compute_label(self) -> str:
return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\"" return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\""
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title', 'body']
class Config:
title = 'Template de clause'
class ProvisionTemplateReference(BaseModel): class ProvisionTemplateReference(BaseModel):
provision_template_id: PydanticObjectId = Field( model_config = ConfigDict(title="Clause")
foreignKey={
"reference": { provision_template_id: PydanticObjectId = ForeignKey(
"resource": "templates/provisions", "templates/provisions",
"schema": "ProvisionTemplate", "ProvisionTemplate",
"displayedFields": ['title', 'body'] ['title', 'body'],
},
},
props={"parametrized": True}, props={"parametrized": True},
title="Template de clause" title="Template de clause"
) )
class Config:
title = 'Clause'
class ContractTemplate(CrudDocument): class ContractTemplate(CrudDocument):
""" """
Modèle de contrat à décliner Modèle de contrat à décliner
""" """
model_config = ConfigDict(title="Template de contrat")
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = Field(title="Titre") title: str = Field(title="Titre")
parties: List[PartyTemplate] = Field(default=[], title="Parties") parties: List[PartyTemplate] = Field(default=[], title="Parties")
@@ -91,12 +64,6 @@ class ContractTemplate(CrudDocument):
def compute_label(self) -> str: def compute_label(self) -> str:
return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\"" return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\""
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title']
class Config:
title = 'Template de contrat'
class ContractTemplateFilters(FilterSchema): class ContractTemplateFilters(FilterSchema):
class Constants(Filter.Constants): class Constants(Filter.Constants):

View File

@@ -1,4 +1,4 @@
from pydantic import Field from pydantic import Field, ConfigDict
from typing import List from typing import List
from firm.template.models import ContractTemplate, ProvisionTemplate, PartyTemplate, ProvisionTemplateReference, DictionaryEntry from firm.template.models import ContractTemplate, ProvisionTemplate, PartyTemplate, ProvisionTemplateReference, DictionaryEntry
@@ -11,6 +11,8 @@ class ContractTemplateRead(Reader, ContractTemplate):
class ContractTemplateCreate(Writer): class ContractTemplateCreate(Writer):
model_config = ConfigDict(title="Template de Contrat")
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = Field(title="Titre") title: str = Field(title="Titre")
parties: List[PartyTemplate] = Field( parties: List[PartyTemplate] = Field(
@@ -28,9 +30,6 @@ class ContractTemplateCreate(Writer):
title="Variables" title="Variables"
) )
class Config:
title = 'Template de Contrat'
class ContractTemplateUpdate(ContractTemplateCreate): class ContractTemplateUpdate(ContractTemplateCreate):
pass pass
@@ -41,13 +40,12 @@ class ProvisionTemplateRead(Reader, ProvisionTemplate):
class ProvisionTemplateCreate(Writer): class ProvisionTemplateCreate(Writer):
model_config = ConfigDict(title="Template de Clause")
name: str = Field(title="Nom") name: str = Field(title="Nom")
title: str = RichtextSingleline(title="Titre") title: str = RichtextSingleline(title="Titre")
body: str = RichtextMultiline(title="Corps") body: str = RichtextMultiline(title="Corps")
class Config:
title = 'Template de Clause'
class ProvisionTemplateUpdate(ProvisionTemplateCreate): class ProvisionTemplateUpdate(ProvisionTemplateCreate):
pass pass

View File

@@ -67,7 +67,7 @@ class AuthenticationBackendMe(AuthenticationBackend):
class CookieTransportOauth(CookieTransport): class CookieTransportOauth(CookieTransport):
async def get_login_response(self, token: str) -> Response: async def get_login_response(self, token: str) -> Response:
response = RedirectResponse("/auth/login?oauth=success", status_code=status.HTTP_301_MOVED_PERMANENTLY) response = RedirectResponse("/login?oauth=success", status_code=status.HTTP_301_MOVED_PERMANENTLY)
return self._set_login_cookie(response, token) return self._set_login_cookie(response, token)
@staticmethod @staticmethod

View File

@@ -5,7 +5,7 @@ from hub import hub_router
from hub.db import init_db as hub_init_db from hub.db import init_db as hub_init_db
from firm import firm_router from firm import firm_router
from firm.db import init_db as firm_init_db from firm.init_db import init_db as firm_init_db
if __name__ == '__main__': if __name__ == '__main__':

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"@mui/lab": "^6.0.0-beta.14", "@mui/lab": "^6.0.0-beta.14",
"@mui/material": "^6.1.7", "@mui/material": "^6.1.7",
"@mui/x-data-grid": "^7.22.2", "@mui/x-data-grid": "^7.22.2",
"@mui/x-date-pickers": "^8.3.0",
"@refinedev/cli": "^2.16.21", "@refinedev/cli": "^2.16.21",
"@refinedev/core": "^4.47.1", "@refinedev/core": "^4.47.1",
"@refinedev/devtools": "^1.1.32", "@refinedev/devtools": "^1.1.32",
@@ -39,6 +40,7 @@
"mui-tiptap": "^1.18.1", "mui-tiptap": "^1.18.1",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.30.0", "react-hook-form": "^7.30.0",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-router": "^7.0.2" "react-router": "^7.0.2"

View File

@@ -229,7 +229,7 @@
"resource_plural": "Contract Templates" "resource_plural": "Contract Templates"
}, },
"party_template": { "party_template": {
"entity_id": "Party Template", "entity_id": "Entity",
"representative_id": "Representative", "representative_id": "Representative",
"part": "Part", "part": "Part",
"resource_title": "Party" "resource_title": "Party"

View File

@@ -189,7 +189,7 @@
}, },
"corporation": { "corporation": {
"type": "Entreprise", "type": "Entreprise",
"title": "Titre", "title": "Dénomination sociale",
"activity": "Activité", "activity": "Activité",
"employees": "Employés", "employees": "Employés",
"resource_title": "Entreprise" "resource_title": "Entreprise"

View File

@@ -6,11 +6,7 @@ import { RefineSnackbarProvider, useNotificationProvider } from "@refinedev/mui"
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
import GlobalStyles from "@mui/material/GlobalStyles"; import GlobalStyles from "@mui/material/GlobalStyles";
import HistoryEduIcon from '@mui/icons-material/HistoryEdu'; import HistoryEduIcon from '@mui/icons-material/HistoryEdu';
import routerBindings, { import routerBindings, { DocumentTitleHandler, UnsavedChangesNotifier } from "@refinedev/react-router";
CatchAllNavigate,
DocumentTitleHandler,
UnsavedChangesNotifier,
} from "@refinedev/react-router";
import { BrowserRouter, Outlet, Route, Routes } from "react-router"; import { BrowserRouter, Outlet, Route, Routes } from "react-router";
import authProvider from "./providers/auth-provider"; import authProvider from "./providers/auth-provider";
import dataProvider from "./providers/data-provider"; import dataProvider from "./providers/data-provider";
@@ -61,7 +57,8 @@ function App() {
queries: { queries: {
retry: (failureCount, error) => { retry: (failureCount, error) => {
// @ts-ignore // @ts-ignore
if (error.statusCode >= 400 && error.statusCode <= 499) { const status = error.statusCode ? error.statusCode : error.status
if (status >= 400 && status<= 499) {
return false return false
} }
return failureCount < 4 return failureCount < 4
@@ -76,7 +73,7 @@ function App() {
<Routes> <Routes>
<Route <Route
element={( element={(
<Authenticated key="authenticated-routes" redirectOnFail="/login" fallback={<CatchAllNavigate to="/login"/>}> <Authenticated key="authenticated-routes" fallback={<Login />}>
<Outlet /> <Outlet />
</Authenticated> </Authenticated>
)} )}

View File

@@ -3,6 +3,8 @@ import { useTranslation } from "@refinedev/core";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import * as locales from '@mui/material/locale'; import * as locales from '@mui/material/locale';
import { createTheme, ThemeProvider } from "@mui/material/styles"; import { createTheme, ThemeProvider } from "@mui/material/styles";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
type SupportedLocales = keyof typeof locales; type SupportedLocales = keyof typeof locales;
@@ -10,11 +12,13 @@ export const I18nTheme: React.FC<PropsWithChildren> = ({ children }: PropsWithCh
const { getLocale } = useTranslation(); const { getLocale } = useTranslation();
const theme = useTheme() const theme = useTheme()
const themeWithLocale = createTheme(theme, locales[getLocale() as SupportedLocales]) const locale = getLocale() || "en"
const themeWithLocale = createTheme(theme, locales[locale as SupportedLocales])
return ( return (
<ThemeProvider theme={themeWithLocale}> <ThemeProvider theme={themeWithLocale}>
{ children } <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale.slice(0,2)} >
{ children }
</LocalizationProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -117,8 +117,8 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
anchorEl={anchorEl} anchorEl={anchorEl}
open={openUserMenu} open={openUserMenu}
onClose={handleCloseUserMenu} onClose={handleCloseUserMenu}
MenuListProps={{ slotProps={{
'aria-labelledby': 'user-menu-button', list:{ 'aria-labelledby': 'user-menu-button' }
}} }}
> >
<MenuItem onClick={handleCloseUserMenu}><Logout /></MenuItem> <MenuItem onClick={handleCloseUserMenu}><Logout /></MenuItem>

View File

@@ -3,6 +3,8 @@ import { IFirm } from "../interfaces";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { useOne } from "@refinedev/core"; import { useOne } from "@refinedev/core";
import { CircularProgress } from "@mui/material"; import { CircularProgress } from "@mui/material";
import { FirmInitForm } from "../pages/firm";
import { Header } from "../components";
type FirmContextType = { type FirmContextType = {
currentFirm: IFirm, currentFirm: IFirm,
@@ -19,19 +21,27 @@ export const FirmContextProvider: React.FC<PropsWithChildren> = ({ children }: P
const { data, isError, error, isLoading } = useOne({resource: 'firm', id: `${instance}/${firm}/`, errorNotification: false}); const { data, isError, error, isLoading } = useOne({resource: 'firm', id: `${instance}/${firm}/`, errorNotification: false});
if (instance === undefined || firm === undefined) { if (instance === undefined || firm === undefined) {
return "Error" throw({statusCode: 400});
} }
const currentFirm: IFirm = { instance, firm }
if (isLoading) { if (isLoading) {
return <CircularProgress /> return <CircularProgress />
} }
let value: FirmContextType = { if (isError && error) {
currentFirm: {instance, firm} if (error.statusCode == 405) {
return <><Header /><FirmInitForm currentFirm={currentFirm} /></>
}
if (error.statusCode == 404) {
throw error;
}
} }
if (!isError || error?.statusCode != 405) {
value.currentFirm.entity = data?.data.entity; currentFirm.entity = data?.data.entity;
value.partnerMap = new Map(data?.data.partner_list.map((item: any) => [item.id, item.label])); let value: FirmContextType = {
currentFirm: currentFirm,
partnerMap: new Map(data?.data.partner_list.map((item: any) => [item.id, item.label])),
} }
return ( return (

View File

@@ -7,6 +7,8 @@ import ArrayFieldTemplate from "./templates/ArrayFieldTemplate"
import ArrayFieldItemTemplate from "./templates/ArrayFieldItemTemplate"; import ArrayFieldItemTemplate from "./templates/ArrayFieldItemTemplate";
import { ResourceContext } from "../contexts/ResourceContext"; import { ResourceContext } from "../contexts/ResourceContext";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ParametersContextProvider } from "../contexts/parameters-context";
import CrudArrayField from "./fields/crud-array-field";
type BaseFormProps = { type BaseFormProps = {
schema: RJSFSchema, schema: RJSFSchema,
@@ -19,11 +21,12 @@ type BaseFormProps = {
} }
export const customWidgets: RegistryWidgetsType = { export const customWidgets: RegistryWidgetsType = {
TextWidget: CrudTextWidget TextWidget: CrudTextWidget,
}; };
export const customFields: RegistryFieldsType = { export const customFields: RegistryFieldsType = {
AnyOfField: UnionEnumField AnyOfField: UnionEnumField,
ArrayField: CrudArrayField
} }
const customTemplates = { const customTemplates = {
@@ -36,19 +39,21 @@ export const BaseForm: React.FC<BaseFormProps> = (props) => {
return ( return (
<ResourceContext.Provider value={{basePath: resourceBasePath}} > <ResourceContext.Provider value={{basePath: resourceBasePath}} >
<Form <ParametersContextProvider>
schema={schema} <Form
uiSchema={uiSchema === undefined ? {} : uiSchema} schema={schema}
formData={formData} uiSchema={uiSchema === undefined ? {} : uiSchema}
onSubmit={(e, id) => onSubmit != undefined && onSubmit(e.formData)} formData={formData}
validator={validator} onSubmit={(e, id) => onSubmit != undefined && onSubmit(e.formData)}
omitExtraData={true} validator={validator}
widgets={customWidgets} omitExtraData={true}
fields={customFields} widgets={customWidgets}
templates={customTemplates} fields={customFields}
onChange={(e, id) => onChange != undefined && onChange(e.formData)} templates={customTemplates}
children={children} onChange={(e, id) => onChange != undefined && onChange(e.formData)}
/> children={children}
/>
</ParametersContextProvider>
</ResourceContext.Provider> </ResourceContext.Provider>
) )
} }

View File

@@ -0,0 +1,73 @@
import { Accordion, AccordionDetails, AccordionSummary, CircularProgress } from "@mui/material";
import FilterForm from "../../filter-form/components/filter-form";
import TextField from "@mui/material/TextField";
import { GridExpandMoreIcon } from "@mui/x-data-grid";
import { useResourceFilter } from "../hook";
export type OnChangeValue = {
search: string | null
filters: {[filter: string]: {[op: string]: string}}
}
type CrudFiltersProps = {
resourceName: string
resourcePath: string
onChange: (value: OnChangeValue) => void
}
const CrudFilters = (props: CrudFiltersProps) => {
const { resourceName, resourcePath, onChange } = props
const { hasSearch, filtersSchema, filtersLoading } = useResourceFilter(resourceName, resourcePath)
if (filtersLoading) {
return <CircularProgress />
}
let currentValue = {
search: "",
filters: {}
}
return (
<>
{hasSearch &&
<SearchFilter value="" onChange={(value) => {
currentValue.search = value;
onChange(currentValue);
}}
/>
}
<Accordion>
<AccordionSummary
expandIcon={<>Advanced filters<GridExpandMoreIcon /></>}
/>
<AccordionDetails>
<FilterForm fields={filtersSchema} values={{}} onChange={(value) => {
currentValue.filters = value;
onChange(currentValue);
}}
/>
</AccordionDetails>
</Accordion>
</>
)
}
export default CrudFilters;
type SearchFilter = {
value: string
onChange: (value: any) => void
}
const SearchFilter = (props: SearchFilter) => {
const {value, onChange} = props;
return (
<TextField
label="schemas.search"
fullWidth={true}
onChange={(event) => onChange(event.target.value)}
defaultValue={value}
/>
)
}

View File

@@ -1,9 +1,8 @@
import { ReactNode, useEffect, useState } from "react"; import { ReactNode } from "react";
import { CircularProgress } from "@mui/material"; import { CircularProgress } from "@mui/material";
import { useForm } from "@refinedev/core";
import { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { BaseForm } from "./base-form"; import { BaseForm } from "./base-form";
import { useResourceSchema } from "../hook";
type CrudFormProps = { type CrudFormProps = {
schemaName: string, schemaName: string,
@@ -18,31 +17,8 @@ type CrudFormProps = {
export const CrudForm: React.FC<CrudFormProps> = (props) => { export const CrudForm: React.FC<CrudFormProps> = (props) => {
const { schemaName, uiSchema, record, resourceBasePath, defaultValue, children, onSubmit=(data: any) => {}, card=false } = props; const { schemaName, uiSchema, record, resourceBasePath, defaultValue, children, onSubmit=(data: any) => {}, card=false } = props;
const type = record === undefined ? "create" : card ? "card" : "update"
const [schema, setSchema] = useState({}); const { schema, schemaLoading } = useResourceSchema(schemaName, type);
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
let resourceSchema
if (record === undefined) {
resourceSchema = await jsonschemaProvider.getCreateResourceSchema(schemaName);
} else {
if (card) {
resourceSchema = await jsonschemaProvider.getCardResourceSchema(schemaName);
} else {
resourceSchema = await jsonschemaProvider.getUpdateResourceSchema(schemaName);
}
}
setSchema(resourceSchema);
setSchemaLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
if(schemaLoading) { if(schemaLoading) {
return <CircularProgress /> return <CircularProgress />

View File

@@ -0,0 +1,56 @@
import { CircularProgress } from "@mui/material";
import { DataGrid, GridColDef, GridColumnVisibilityModel, GridValidRowModel } from "@mui/x-data-grid";
import { UiSchema } from "@rjsf/utils";
import { useResourceColumns } from "../hook";
type CrudListProps = {
schemaName: string,
uiSchema?: UiSchema,
columnDefinitions: ColumnDefinition[],
dataGridProps: any,
resourceBasePath: string,
onRowClick?: (params: any) => void
}
type ColumnSchema<T extends GridValidRowModel> = {
columns: GridColDef<T>[],
columnVisibilityModel: GridColumnVisibilityModel
}
type ColumnDefinition = {
field: string,
column: Partial<GridColDef>,
hide?: boolean
}
const CrudList = <T extends GridValidRowModel>(props: CrudListProps) => {
const {
dataGridProps,
onRowClick,
schemaName,
columnDefinitions
} = props;
const { columnSchema, columnLoading } = useResourceColumns<T>(schemaName, columnDefinitions);
if (columnLoading || columnSchema === undefined) {
return <CircularProgress />
}
return (
<DataGrid
{...dataGridProps}
columns={columnSchema.columns}
onRowClick={onRowClick}
pageSizeOptions={[10, 15, 25, 50, 100]}
disableColumnFilter={true}
initialState={{
columns: {
columnVisibilityModel: columnSchema.columnVisibilityModel
}
}}
/>
)
}
export default CrudList;

View File

@@ -0,0 +1,146 @@
import React, { useContext } from "react";
import { JSONSchema7Definition } from "json-schema";
import {
FieldProps,
FormContextType,
getTemplate, getUiOptions,
RJSFSchema,
} from "@rjsf/utils";
import ArrayField from "@rjsf/core/lib/components/fields/ArrayField";
import validator from "@rjsf/validator-ajv8";
import Form from "@rjsf/mui";
import Typography from "@mui/material/Typography";
import { Box, Paper } from "@mui/material";
import { CrudTextRJSFSchema } from "../widgets/crud-text-widget";
import { ParametersContext } from "../../contexts/parameters-context";
export type CrudArrayFieldSchema = RJSFSchema & {
props? : any
}
const CrudArrayField = <T = any, S extends CrudArrayFieldSchema = CrudArrayFieldSchema, F extends FormContextType = any> (props: FieldProps<T[], S, F>)=> {
const { schema } = props
let isDictionary = false;
if (schema.props) {
if (schema.props.hasOwnProperty("display")) {
if (schema.props.display == "dictionary") {
isDictionary = true;
}
}
}
if (isDictionary) {
return <Dictionary {...props} />
}
return <ArrayField<T,S,F> {...props}/>
}
export default CrudArrayField;
type DictionaryEntry = {
key: string
value: string
};
const Dictionary = <
T = any,
S extends CrudTextRJSFSchema = CrudTextRJSFSchema,
F extends FormContextType = any
>(props: FieldProps<T[], S, F>)=> {
const { required, formData, onChange, registry, uiSchema, idSchema, schema } = props;
const uiOptions = getUiOptions<T[], S, F>(uiSchema);
const { parameters } = useContext(ParametersContext);
const ArrayFieldDescriptionTemplate = getTemplate<'ArrayFieldDescriptionTemplate', T[], S, F>(
'ArrayFieldDescriptionTemplate',
registry,
uiOptions
);
const ArrayFieldTitleTemplate = getTemplate<'ArrayFieldTitleTemplate', T[], S, F>(
'ArrayFieldTitleTemplate',
registry,
uiOptions
);
let properties = new Set<string>()
for (const field in parameters) {
for (const param of parameters[field]) {
properties.add(param)
}
}
let data: {[key:string]: string} = {}
if (formData !== undefined) {
for (const param of formData) {
// @ts-ignore
data[param.key] = param.value;
}
}
const emptyDict = Object.values(data).length == 0;
return (
<Paper elevation={2}>
<Box p={2}>
<ArrayFieldTitleTemplate
idSchema={idSchema}
title={uiOptions.title || schema.title}
schema={schema}
uiSchema={uiSchema}
required={required}
registry={registry}
/>
<ArrayFieldDescriptionTemplate
idSchema={idSchema}
description={uiOptions.description || schema.description}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
{ emptyDict && <Typography>No variables found</Typography>}
{ !emptyDict && (
<Form
schema={getFormSchema(Array.from(properties.values()), required || false)}
tagName="div"
formData={data}
validator={validator}
omitExtraData={true}
onChange={(e, id) => {
console.log(e)
let value: T[] = []
for (const prop of properties) {
value.push({
key: prop,
value: e.formData.hasOwnProperty(prop) ? e.formData[prop] : undefined
} as T)
}
onChange(value)
}}
>
&nbsp;
</Form>
)}
</Box>
</Paper>
);
}
function getFormSchema(properties: string[], isRequired: boolean) {
const schema: JSONSchema7Definition = {
type: "object",
properties: {},
};
let required: string[] = []
for (const pname of properties) {
schema.properties![pname] = {
type: "string",
title: pname
}
if (isRequired) {
required.push(pname)
}
}
schema.required = required
return schema
}

View File

@@ -9,7 +9,6 @@ import {
FormContextType, FormContextType,
} from '@rjsf/utils'; } from '@rjsf/utils';
import { CrudTextRJSFSchema } from "../widgets/crud-text-widget"; import { CrudTextRJSFSchema } from "../widgets/crud-text-widget";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
/** The `ArrayFieldTemplate` component is the template used to render all items in an array. /** The `ArrayFieldTemplate` component is the template used to render all items in an array.
@@ -45,7 +44,7 @@ export default function ArrayFieldTemplate<
} = registry.templates; } = registry.templates;
let gridSize = 12; let gridSize = 12;
let numbered = false let numbered = false;
if (schema.props) { if (schema.props) {
if (schema.props.hasOwnProperty("items_per_row")) { if (schema.props.hasOwnProperty("items_per_row")) {
gridSize = gridSize / schema.props.items_per_row; gridSize = gridSize / schema.props.items_per_row;
@@ -74,17 +73,18 @@ export default function ArrayFieldTemplate<
registry={registry} registry={registry}
/> />
<Grid2 container justifyContent='flex-start'> <Grid2 container justifyContent='flex-start'>
{items && { items &&
items.map(({ key, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>, index) => ( items.map(({ key, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>, index) => (
<Grid2 key={key} size={gridSize} > <Grid2 key={key} size={gridSize} >
<Grid2 container sx={{alignItems: "center"}} > <Grid2 container sx={{alignItems: "center"}} >
{numbered &&<Grid2 size={1} ><Typography variant="h4">{index + 1}</Typography></Grid2>} {numbered &&<Grid2 size={.5} ><Typography variant="h4">{index + 1}</Typography></Grid2>}
<Grid2 size={numbered ? 11 : 12} ><ArrayFieldItemTemplate key={key} {...itemProps} /></Grid2> <Grid2 size={numbered ? 11.5 : 12} ><ArrayFieldItemTemplate key={key} {...itemProps} /></Grid2>
</Grid2> </Grid2>
</Grid2> </Grid2>
))} ))
}
</Grid2> </Grid2>
{canAdd && ( { canAdd && (
<Grid2 container justifyContent='flex-end'> <Grid2 container justifyContent='flex-end'>
<Grid2> <Grid2>
<Box mt={2}> <Box mt={2}>

View File

@@ -1,7 +1,10 @@
import React from "react"; import React from "react";
import { getDefaultRegistry } from "@rjsf/core"; import { getDefaultRegistry } from "@rjsf/core";
import { FormContextType, getTemplate, RJSFSchema, WidgetProps } from "@rjsf/utils"; import { FormContextType, RJSFSchema, WidgetProps } from "@rjsf/utils";
import { Button, InputAdornment } from "@mui/material";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import CopyAllIcon from '@mui/icons-material/CopyAll';
import ForeignKeyWidget from "./foreign-key"; import ForeignKeyWidget from "./foreign-key";
import RichtextWidget from "./richtext"; import RichtextWidget from "./richtext";
@@ -18,8 +21,37 @@ export default function CrudTextWidget<T = any, S extends CrudTextRJSFSchema = C
return <Typography >{schema.const as string}</Typography>; return <Typography >{schema.const as string}</Typography>;
} else if (schema.props?.hasOwnProperty("richtext")) { } else if (schema.props?.hasOwnProperty("richtext")) {
return <RichtextWidget {...props} />; return <RichtextWidget {...props} />;
} else if (schema.props?.hasOwnProperty("display") && schema.props.display == "signature-link") {
return <SignatureLink {...props} />
} else { } else {
const { widgets: { TextWidget } } = getDefaultRegistry<T,S,F>(); const { widgets: { TextWidget } } = getDefaultRegistry<T,S,F>();
return <TextWidget {...props} />; return <TextWidget {...props} />;
} }
} }
const SignatureLink = <T = any, S extends CrudTextRJSFSchema = CrudTextRJSFSchema, F extends FormContextType = any>(props: WidgetProps<T, S, F> )=> {
const { label, value } = props;
const basePath = "/contracts/signature/";
const url = location.origin + basePath + value
return <TextField
label={ label }
variant="outlined"
disabled={true}
value={url}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<Button
variant="outlined"
onClick={() => navigator.clipboard.writeText(url)}
color="primary"
>
<CopyAllIcon />
</Button>
</InputAdornment>),
},
}}
/>
}

View File

@@ -6,9 +6,10 @@ import ClearIcon from '@mui/icons-material/Clear';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import NoteAddIcon from '@mui/icons-material/NoteAdd'; import NoteAddIcon from '@mui/icons-material/NoteAdd';
import React, { useState, useEffect, useContext, Fragment } from "react"; import React, { useState, useEffect, useContext, Fragment } from "react";
import { useList, useOne } from "@refinedev/core"; import { useForm, useList, useOne } from "@refinedev/core";
import { ResourceContext } from "../../contexts/ResourceContext"; import { ResourceContext } from "../../contexts/ResourceContext";
import { CrudForm } from "../crud-form"; import { CrudForm } from "../crud-form";
import { ParametersContext } from "../../contexts/parameters-context";
export type ForeignKeyReference = { export type ForeignKeyReference = {
resource: string, resource: string,
@@ -21,6 +22,7 @@ export type ForeignKeySchema = RJSFSchema & {
foreignKey?: { foreignKey?: {
reference: ForeignKeyReference reference: ForeignKeyReference
} }
props? : any
} }
export default function ForeignKeyWidget<T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>( export default function ForeignKeyWidget<T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
@@ -42,10 +44,10 @@ export default function ForeignKeyWidget<T = any, S extends ForeignKeySchema = F
const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>( const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
props: WidgetProps<T, S, F> props: WidgetProps<T, S, F>
) => { ) => {
if (props.schema.foreignKey === undefined) { const { onChange, label, fieldId, schema } = props;
if (schema.foreignKey === undefined) {
return; return;
} }
const { onChange, label } = props
const [openFormModal, setOpenFormModal] = useState(false); const [openFormModal, setOpenFormModal] = useState(false);
const [searchString, setSearchString] = useState<string>(""); const [searchString, setSearchString] = useState<string>("");
@@ -55,12 +57,19 @@ const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [searchString]); }, [searchString]);
const { resource, schema, label: labelField = "label" } = props.schema.foreignKey.reference const { setFieldParameters } = useContext(ParametersContext)
useEffect(() => {
if (schema.hasOwnProperty("props") && schema.props.hasOwnProperty("parametrized") && schema.props.parametrized) {
setFieldParameters(fieldId, [])
}
}, [])
const { resource, schema: fkSchema, label: labelField = "label" } = schema.foreignKey.reference
const { basePath } = useContext(ResourceContext) const { basePath } = useContext(ResourceContext)
const { data, isLoading } = useList({ const { data, isLoading } = useList({
resource: `${basePath}/${resource}`, resource: `${basePath}/${resource}`,
pagination: { current: 1, pageSize: 10, mode: "server" }, pagination: { current: 1, pageSize: 10, mode: "server" },
filters: [{ field: "label", operator: "contains", value: debouncedInputValue }], filters: [{ field: "search", operator: "contains", value: debouncedInputValue }],
sorters: [{ field: "label", order: "asc" }], sorters: [{ field: "label", order: "asc" }],
}); });
@@ -103,14 +112,14 @@ const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<DialogContent> <DialogContent>
<FormContainer <FormContainerNew
schemaName={schema} schemaName={fkSchema}
resourceBasePath={basePath} resourceBasePath={basePath}
resource={resource} resource={resource}
uiSchema={{}} uiSchema={{}}
onSuccess={(data: any) => { onSuccess={(data: any) => {
setOpenFormModal(false) setOpenFormModal(false)
onChange(data.data.id); onChange(data.id);
}} }}
/> />
</DialogContent> </DialogContent>
@@ -122,28 +131,37 @@ const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema
const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>( const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
props: WidgetProps<T, S, F> & { onClear: () => void } props: WidgetProps<T, S, F> & { onClear: () => void }
) => { ) => {
const { onClear, value } = props; const { onClear, value, schema, id: fieldId } = props;
const [openFormModal, setOpenFormModal] = React.useState(false); const [openFormModal, setOpenFormModal] = React.useState(false);
if (props.schema.foreignKey === undefined) { if (props.schema.foreignKey === undefined) {
return; return;
} }
const { resource, schema, label: labelField = "label", displayedFields } = props.schema.foreignKey.reference const { resource, schema: fkSchema, label: labelField = "label", displayedFields } = props.schema.foreignKey.reference
const { basePath } = useContext(ResourceContext) const { basePath } = useContext(ResourceContext)
const { data, isLoading } = useOne({ const { data, isLoading, isSuccess } = useOne({
resource: `${basePath}/${resource}`, resource: `${basePath}/${resource}`,
id: value id: value
}); });
const { setFieldParameters } = useContext(ParametersContext)
useEffect(() => {
if (isSuccess && schema.hasOwnProperty("props") && schema.props.hasOwnProperty("parametrized") && schema.props.parametrized) {
const record = data.data;
setFieldParameters(fieldId, extractParameters(record))
}
}, [isSuccess])
if (isLoading || data === undefined) { if (isLoading || data === undefined) {
return <CircularProgress /> return <CircularProgress />
} }
const record = data.data;
return ( return (
<> <>
<TextField label={ props.label } variant="outlined" disabled={true} value={data.data[labelField]} <TextField label={ props.label } variant="outlined" disabled={true} value={record[labelField]}
slotProps={{ slotProps={{
input: { input: {
endAdornment: ( endAdornment: (
@@ -158,7 +176,7 @@ const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F e
}, },
}} }}
/> />
{ displayedFields && <Preview id={value} basePath={basePath} resource={resource} displayedFields={displayedFields}/>} { displayedFields && <Preview record={record} displayedFields={displayedFields}/>}
<Modal <Modal
open={openFormModal} open={openFormModal}
onClose={() => setOpenFormModal(false)} onClose={() => setOpenFormModal(false)}
@@ -166,8 +184,8 @@ const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F e
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<DialogContent> <DialogContent>
<FormContainer <FormContainerEdit
schemaName={schema} schemaName={fkSchema}
resourceBasePath={basePath} resourceBasePath={basePath}
resource={resource} resource={resource}
uiSchema={{}} uiSchema={{}}
@@ -203,26 +221,54 @@ type FormContainerProps = {
onSuccess: (data: any) => void onSuccess: (data: any) => void
} }
const FormContainer = (props: FormContainerProps) => { const FormContainerEdit = (props: FormContainerProps) => {
const { schemaName, resourceBasePath, resource, uiSchema = {}, id = undefined, onSuccess } = props; const { schemaName, resourceBasePath, resource, uiSchema = {}, id, onSuccess } = props;
const { onFinish, query, formLoading } = useForm({
resource: `${resourceBasePath}/${resource}`,
action: "edit",
id: id,
});
if (formLoading || query?.data === undefined) {
return <CircularProgress />
}
return ( return (
<Box sx={{ ...modalStyle, width: 800 }}> <Box sx={{ ...modalStyle, width: 800 }}>
<CrudForm schemaName={schemaName} resourceBasePath={resourceBasePath} resource={resource} uiSchema={uiSchema} id={id} onSuccess={(data) => onSuccess(data)} /> <CrudForm
schemaName={schemaName}
uiSchema={uiSchema}
resourceBasePath={resourceBasePath}
record={query.data.data}
onSubmit={(data:any) => {
onFinish(data);
onSuccess(data);
}} />
</Box> </Box>
) )
} }
const Preview = (props: {id: string, resource: string, basePath: string, displayedFields: [string]}) => { const FormContainerNew = (props: FormContainerProps) => {
const { basePath, resource, id, displayedFields } = props const { schemaName, resourceBasePath, resource, uiSchema = {}, onSuccess } = props;
const { onFinish } = useForm({
const { data, isLoading } = useOne({ resource: `${resourceBasePath}/${resource}`,
resource: `${basePath}/${resource}`, action: "create",
id onMutationSuccess: data => onSuccess(data.data)
}); });
if (isLoading || data === undefined) { return (
return <CircularProgress /> <Box sx={{ ...modalStyle, width: 800 }}>
} <CrudForm
schemaName={schemaName}
uiSchema={uiSchema}
resourceBasePath={resourceBasePath}
onSubmit={(data:any) => { onFinish(data);}}
/>
</Box>
)
}
const Preview = (props: {record: any, displayedFields: [string]}) => {
const { record, displayedFields } = props
return ( return (
<Grid2 container spacing={2}> <Grid2 container spacing={2}>
@@ -230,10 +276,29 @@ const Preview = (props: {id: string, resource: string, basePath: string, display
return ( return (
<Fragment key={index}> <Fragment key={index}>
<Grid2 size={2}><Container>{field}</Container></Grid2> <Grid2 size={2}><Container>{field}</Container></Grid2>
<Grid2 size={9}><Container dangerouslySetInnerHTML={{ __html: data.data[field] }} ></Container></Grid2> <Grid2 size={9}><Container dangerouslySetInnerHTML={{ __html: record[field] }} ></Container></Grid2>
</Fragment> </Fragment>
) )
})} })}
</Grid2> </Grid2>
); );
} }
const extractParameters = (obj: any)=> {
let result: string[] = [];
for (const [k, v] of Object.entries(obj)) {
if (typeof(obj[k]) == "string") {
const matches = obj[k].match(/%[^\s.]+%/g);
if (matches) {
const filtered = matches.map((p: string | any[]) => p.slice(1,-1)) as string[]
result = result.concat(filtered);
}
} else if (typeof(obj[k]) == "object") {
if (obj[k]) {
result = result.concat(extractParameters(obj[k]));
}
}
}
return result
}

View File

@@ -1,4 +1,4 @@
import React, { createContext, PropsWithChildren } from 'react'; import { createContext } from 'react';
type ResourceContextType = { type ResourceContextType = {
basePath: string, basePath: string,

View File

@@ -0,0 +1,30 @@
import React, { createContext, PropsWithChildren, useState } from 'react';
type Parameters = {
[field: string]: string[]
}
type ParametersContextType = {
parameters: Parameters,
setFieldParameters: (fieldName: string, parameterList: string[]) => void
}
export const ParametersContext = createContext<ParametersContextType>(
{} as ParametersContextType
);
export const ParametersContextProvider: React.FC<PropsWithChildren> = ({ children }: PropsWithChildren) => {
const [parameters, setParameters] = useState<Parameters>({});
function setFieldParameters(fieldName: string, parameterList: string[]) {
let params = structuredClone(parameters)
params[fieldName] = parameterList
setParameters(params);
}
return (
<ParametersContext.Provider value={{ parameters, setFieldParameters }} >
{children}
</ParametersContext.Provider>
);
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { GridColDef, GridColumnVisibilityModel, GridValidRowModel } from "@mui/x-data-grid";
type ResourceSchemaType = "create" | "update" | "card";
export function useResourceSchema(schemaName: string, type: ResourceSchemaType) {
const [schema, setSchema] = useState({});
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
let resourceSchema
if (type == "create") {
resourceSchema = await jsonschemaProvider.getCreateResourceSchema(schemaName);
} else if (type == "card") {
resourceSchema = await jsonschemaProvider.getCardResourceSchema(schemaName);
} else {
resourceSchema = await jsonschemaProvider.getUpdateResourceSchema(schemaName);
}
setSchema(resourceSchema);
setSchemaLoading(false);
} catch (error) {
console.error(`Error while retrieving schema: ${schemaName} `, error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
return { schema, schemaLoading }
}
type ColumnSchema<T extends GridValidRowModel> = {
columns: GridColDef<T>[],
columnVisibilityModel: GridColumnVisibilityModel
}
type ColumnDefinition = {
field: string,
column: Partial<GridColDef>,
hide?: boolean
}
export function useResourceColumns<T extends GridValidRowModel>(schemaName: string, columnDefinitions: ColumnDefinition[]) {
const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
const [columnLoading, setColumnLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
const resourceColumns = await jsonschemaProvider.getReadResourceColumns(schemaName)
const definedColumns = computeColumnSchema<T>(columnDefinitions, resourceColumns)
setColumnSchema(definedColumns);
setColumnLoading(false);
} catch (error) {
console.error('Error while retrieving columns schema:', error);
setColumnLoading(false);
}
};
fetchSchema();
}, []);
return { columnSchema, columnLoading }
}
function computeColumnSchema<T extends GridValidRowModel>(definitionColumns: ColumnDefinition[], resourceColumns: GridColDef[]): ColumnSchema<T> {
//reorder resourceColumns as in definition
definitionColumns.slice().reverse().forEach(first => {
resourceColumns.sort(function(x,y){ return x.field == first.field ? -1 : y.field == first.field ? 1 : 0; });
})
let visibilityModel: GridColumnVisibilityModel = {}
resourceColumns.forEach((resource, index) =>{
visibilityModel[resource.field] = definitionColumns.some(col => col.field == resource.field && !col.hide)
definitionColumns.forEach((def) => {
if (def.field == resource.field) {
resourceColumns[index] = {...resource, ...def.column};
}
})
})
return {
columns: resourceColumns,
columnVisibilityModel: visibilityModel
}
}
export function useResourceFilter(resourceName: string, resourcePath: string) {
const [hasSearch, setHasSearch] = useState(false)
const [filtersSchema, setFiltersSchema] = useState<any[]>([])
const [filtersLoading, setFiltersLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
setHasSearch(await jsonschemaProvider.hasSearch(resourcePath))
const resourceFilters = await jsonschemaProvider.getListFilters(resourceName, resourcePath)
setFiltersSchema(resourceFilters);
setFiltersLoading(false);
} catch (error) {
console.error('Error while retrieving filter schema:', error);
setFiltersLoading(false);
}
};
fetchSchema();
}, []);
return { hasSearch, filtersSchema, filtersLoading }
}

View File

@@ -1,6 +1,8 @@
import { RJSFSchema } from '@rjsf/utils'; import { RJSFSchema } from '@rjsf/utils';
import i18n from '../../../i18n' import i18n from '../../../i18n'
import { JSONSchema7Definition } from "json-schema"; import { JSONSchema7Definition } from "json-schema";
import { GridColDef } from "@mui/x-data-grid";
import { GridColType } from "@mui/x-data-grid/models/colDef/gridColType";
const API_URL = "/api/v1"; const API_URL = "/api/v1";
@@ -51,6 +53,10 @@ export const jsonschemaProvider = {
return readSchema return readSchema
}, },
getReadResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Read`)
},
getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => { getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Update`) return getResourceSchema(`${resourceName}Update`)
}, },
@@ -58,53 +64,242 @@ export const jsonschemaProvider = {
getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => { getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Create`) return getResourceSchema(`${resourceName}Create`)
}, },
getReadResourceColumns: async (resourceName: string): Promise<GridColDef[]> => {
return getColumns(`${resourceName}Read`)
},
getListFilters: async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
return getFilters(resourceName, resourcePath);
},
hasSearch: async (resourcePath: string): Promise<boolean> => {
return hasSearch(resourcePath);
}
} }
const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => { const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => {
return buildResource(await getJsonschema(), resourceName) return buildResource(await getJsonschema(), resourceName)
} }
let rawSchema: RJSFSchema; const getColumns = async (resourceName: string): Promise<GridColDef[]> => {
const getJsonschema = async (): Promise<RJSFSchema> => { return buildColumns(await getJsonschema(), resourceName)
if (rawSchema === undefined) {
const response = await fetch(`${API_URL}/openapi.json`,);
rawSchema = await response.json();
}
return rawSchema;
} }
function convertCamelToSnake(str: string): string { const getFilters = async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase() return buildFilters(await getJsonschema(), resourceName, resourcePath);
}
type PathParameter = {
in: string,
name:string
}
type PathSchema = {
parameters: PathParameter[]
}
type FilterField = {
name: string,
label: string,
type: string,
operators: { name: string, label: string }[]
}
type Operator = {
operator: string,
fieldName: string,
}
async function hasSearch(resourcePath: string): Promise<boolean> {
const jst = new JsonSchemaTraverser(await getJsonschema());
const pathSchema = jst.getPath(resourcePath);
for (const param of pathSchema.parameters) {
if (param.name == "search") {
return true
}
}
return false
}
function buildFilters(rawSchema: RJSFSchema, resourceName: string, resourcePath: string): FilterField[] {
const shortResourceName = shortenResourceName(resourceName);
const jst = new JsonSchemaTraverser(rawSchema);
const pathSchema = jst.getPath(resourcePath);
const seen: { [k: string]: number } = {};
let filters: FilterField[] = []
for (const param of pathSchema.parameters) {
if (param.name.indexOf("__") > -1) {
const { operator, fieldName } = processParamName(param)
if (! seen.hasOwnProperty(fieldName)) {
seen[fieldName] = filters.length;
const field = jst.getPropertyByPath(jst.getResource(`${resourceName}Read`), fieldName)
filters.push({
name: fieldName,
label: getPropertyI18nLabel(shortResourceName, fieldName),
type: getFieldFilterType(fieldName, field),
operators: [{ name: operator, label: operator }]
});
} else {
// @ts-ignore
filters[seen[fieldName]].operators?.push({ name: operator, label: operator });
}
}
}
return filters;
}
function processParamName(param: PathParameter): Operator {
const nameParts = param.name.split("__")
return {
operator: nameParts.pop() as string,
fieldName: nameParts.join("."),
}
}
const getFieldFilterType = (fieldName: string, field: RJSFSchema): string => {
if (fieldName == "created_by" || fieldName == "updated_by") {
return "author";
} else if (Array.isArray(field)) {
let enumValues = [];
for (const f of field) {
enumValues.push(f.const)
}
return `enum(${enumValues.join("|")})`
} else if (is_enum(field) && field.enum != undefined) {
return `enum(${field.enum.join("|")})`
} else if (field.hasOwnProperty('type')) {
if (field.type == "string" && field.format == "date-time") {
return "dateTime";
}
return field.type as string;
} else if (field.hasOwnProperty('anyOf') && field.anyOf) {
for (const prop of field.anyOf) {
if (typeof prop != "boolean" && prop.type != "null") {
return prop.type as string;
}
}
return "null";
}
throw "Unimplemented field type"
}
function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: string|undefined = undefined): GridColDef[] {
const shortResourceName = shortenResourceName(resourceName);
const jst = new JsonSchemaTraverser(rawSchemas);
let resource = structuredClone(jst.getResource(resourceName));
let result: GridColDef[] = [];
if (is_enum(resource) && prefix !== undefined) {
return [{
field: prefix,
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prefix)}`) as string,
type: "string"
}];
}
for (const prop_name in resource.properties) {
let prop = resource.properties[prop_name];
if (is_reference(prop)) {
const subresourceName = get_reference_name(prop);
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
} else if (is_union(prop)) {
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
let seen = new Set<string>();
for (let i in union) {
if (is_reference(union[i])) {
const subresourceName = get_reference_name(union[i]);
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
for (const s of subcolumns) {
if (! seen.has(s.field)) {
result.push(s);
seen.add(s.field);
}
}
}
}
} else if (is_enum(prop)) {
let seen = new Set<string>();
for (let i in prop.allOf) {
if (is_reference(prop.allOf[i])) {
const subresourceName = get_reference_name(prop.allOf[i]);
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
for (const s of subcolumns) {
if (! seen.has(s.field)) {
result.push(s);
seen.add(s.field);
}
}
}
}
} else if (is_array(prop) && is_reference(prop.items)) {
const subresourceName = get_reference_name(prop.items);
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
} else {
let valueGetter: undefined|((value: any, row: any) => any) = undefined;
let type: GridColType = "string";
if (is_array(prop)) {
valueGetter = (value: any[], row: any ) => {
return value.concat(".");
}
} else if (prefix !== undefined) {
valueGetter = (value: any, row: any ) => {
let parent = row;
for (const col of prefix.split(".")) {
parent = parent[col];
}
return parent ? parent[prop_name] : "";
}
} else {
if (prop.type == "string" && prop.format == "date-time") {
type = "dateTime"
valueGetter = (value: string) => new Date(value)
}
}
const column: GridColDef = {
field: prefix ? `${prefix}.${prop_name}` : prop_name,
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prop_name)}`, prop.title) as string,
type: type,
valueGetter: valueGetter
}
result.push(column);
}
}
return result;
} }
function buildResource(rawSchemas: RJSFSchema, resourceName: string) { function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
if (rawSchemas.components.schemas[resourceName] === undefined) { const shortResourceName = shortenResourceName(resourceName);
throw new Error(`Resource "${resourceName}" not found in schema.`); const jst = new JsonSchemaTraverser(rawSchemas);
} let resource = structuredClone(jst.getResource(resourceName));
const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|Create|Update)$/g, ""));
let resource = structuredClone(rawSchemas.components.schemas[resourceName]);
resource.components = { schemas: {} }; resource.components = { schemas: {} };
for (let prop_name in resource.properties) { for (let prop_name in resource.properties) {
let prop = resource.properties[prop_name]; let prop = resource.properties[prop_name];
if (is_reference(prop)) { if (is_reference(prop)) {
resolveReference(rawSchemas, resource, prop); buildReference(rawSchemas, resource, prop);
} else if (is_union(prop)) { } else if (is_union(prop)) {
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf; const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
for (let i in union) { for (let i in union) {
if (is_reference(union[i])) { if (is_reference(union[i])) {
resolveReference(rawSchemas, resource, union[i]); buildReference(rawSchemas, resource, union[i]);
} }
} }
} else if (is_enum(prop)) { } else if (is_enum(prop)) {
for (let i in prop.allOf) { for (let i in prop.allOf) {
if (is_reference(prop.allOf[i])) { if (is_reference(prop.allOf[i])) {
resolveReference(rawSchemas, resource, prop.allOf[i]); buildReference(rawSchemas, resource, prop.allOf[i]);
} }
} }
} else if (is_array(prop) && is_reference(prop.items)) { } else if (is_array(prop) && is_reference(prop.items)) {
resolveReference(rawSchemas, resource, prop.items); buildReference(rawSchemas, resource, prop.items);
} }
if (prop.hasOwnProperty("title")) { if (prop.hasOwnProperty("title")) {
@@ -118,7 +313,7 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
return resource; return resource;
} }
function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) { function buildReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) {
const subresourceName = get_reference_name(prop_reference); const subresourceName = get_reference_name(prop_reference);
const subresource = buildResource(rawSchemas, subresourceName); const subresource = buildResource(rawSchemas, subresourceName);
resource.components.schemas[subresourceName] = subresource; resource.components.schemas[subresourceName] = subresource;
@@ -187,81 +382,157 @@ function get_reference_name(prop: any) {
return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1); return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1);
} }
function has_descendant(rawSchemas:RJSFSchema, resource: RJSFSchema, property_name: string): boolean { function convertCamelToSnake(str: string): string {
if (is_array(resource)) { return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase()
return property_name == 'items';
} else if (is_object(resource)) {
return property_name in resource.properties!;
} else if (is_reference(resource)) {
let subresourceName = get_reference_name(resource);
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
} else if (is_union(resource)) {
const union = resource.hasOwnProperty("oneOf") ? resource.oneOf : resource.anyOf;
if (union !== undefined) {
for (const ref of union) {
return has_descendant(rawSchemas, ref as RJSFSchema, property_name)
}
}
} else if (is_enum(resource)) {
for (const ref of resource.allOf!) {
return has_descendant(rawSchemas, ref as RJSFSchema, property_name);
}
}
throw new Error("Jsonschema format not implemented in property finder");
} }
function get_descendant(rawSchemas: RJSFSchema, resource: RJSFSchema, property_name: string): RJSFSchema { function shortenResourceName(resourceName: string) {
if (is_array(resource) && property_name == 'items') { return convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
return resource.items as RJSFSchema; }
} else if (is_object(resource) && resource.properties !== undefined && property_name in resource.properties!) {
return resource.properties[property_name] as RJSFSchema; function getPropertyI18nLabel(shortResourceName: string, fieldName: string): string {
} else if (is_reference(resource)) { if (meta_fields.indexOf(fieldName) > -1) {
let subresourceName = get_reference_name(resource); return i18n.t(`schemas.${convertCamelToSnake(fieldName)}`);
let subresource = buildResource(rawSchemas, subresourceName); }
return get_descendant(rawSchemas, subresource, property_name); const path = `schemas.${shortResourceName}.${convertCamelToSnake(fieldName)}`
} else if (is_union(resource)) { return i18n.t(path)
for (const ref of resource.oneOf!) { }
if (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) {
return get_descendant(rawSchemas, ref as RJSFSchema, property_name); let rawSchema: RJSFSchema;
} const getJsonschema = async (): Promise<RJSFSchema> => {
if (rawSchema === undefined) {
const response = await fetch(`${API_URL}/openapi.json`,);
rawSchema = await response.json();
}
return rawSchema;
}
const JsonSchemaTraverser = class {
private rawSchemas: RJSFSchema;
constructor(rawSchemas: RJSFSchema) {
this.rawSchemas = rawSchemas
}
public getResource = (resourceName: string) => {
if (this.rawSchemas.components.schemas[resourceName] === undefined) {
throw new Error(`Resource "${resourceName}" not found in schema.`);
} }
} else if (is_enum(resource)) {
for (const ref of resource.allOf!) { return this.rawSchemas.components.schemas[resourceName]
if (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) { }
return get_descendant(rawSchemas, ref as RJSFSchema, property_name);
public getPath = (resourcePath: string) => {
const resourceParts = `/${resourcePath}/`.split("/");
let pathSchema: PathSchema|undefined;
for (const path in this.rawSchemas.paths) {
if (this.rawSchemas.paths[path].hasOwnProperty("get")) {
const pathParts = path.split("/")
if (pathParts.length == resourceParts.length) {
for (let i = 0; i < pathParts.length; i++) {
const isVariable = pathParts[i].slice(0,1) == "{" && pathParts[i].slice(-1) == "}";
if (! isVariable && pathParts[i] != resourceParts[i] ) {
break;
}
if (i == pathParts.length - 1) {
pathSchema = this.rawSchemas.paths[path].get
}
} }
}
if (pathSchema !== undefined) {
break
}
} }
} }
throw new Error("property not found or Jsonschema format not implemented"); if (pathSchema === undefined) {
} throw ("Path not found in schema");
}
function path_exists(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): boolean{ return pathSchema
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return has_descendant(rawSchemas, resource, path);
} }
return has_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition)) public hasDescendant = (resource: RJSFSchema, property_name: string): boolean => {
&& path_exists( if (is_array(resource)) {
rawSchemas, return property_name == 'items';
get_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition)), } else if (is_object(resource)) {
return property_name in resource.properties!;
} else if (is_reference(resource)) {
let subresourceName = get_reference_name(resource);
return this.hasDescendant(this.getResource(subresourceName), property_name);
} else if (is_union(resource)) {
const union = resource.hasOwnProperty("oneOf") ? resource.oneOf : resource.anyOf;
if (union !== undefined) {
for (const ref of union) {
return this.hasDescendant(ref as RJSFSchema, property_name)
}
}
} else if (is_enum(resource)) {
for (const ref of resource.allOf!) {
return this.hasDescendant(ref as RJSFSchema, property_name);
}
}
throw new Error("Jsonschema format not implemented in property finder");
}
public getDescendant = (resource: RJSFSchema, property_name: string): RJSFSchema | RJSFSchema[] => {
if (is_array(resource) && property_name == 'items') {
return resource.items as RJSFSchema;
} else if (is_object(resource) && resource.properties !== undefined && property_name in resource.properties!) {
const prop = resource.properties[property_name];
if (is_reference(prop)) {
const subresourceName = get_reference_name(prop);
return this.getResource(subresourceName);
}
return prop as RJSFSchema;
} else if (is_reference(resource)) {
let subresourceName = get_reference_name(resource);
let subresource = this.getResource(subresourceName);
return this.getDescendant(subresource, property_name);
} else if (is_union(resource)) {
let descendants: RJSFSchema[] = [];
for (const ref of resource.oneOf!) {
if (this.hasDescendant(ref as RJSFSchema, property_name)) {
descendants.push(this.getDescendant(ref as RJSFSchema, property_name));
}
}
if (descendants.length > 0) {
return descendants;
}
} else if (is_enum(resource)) {
for (const ref of resource.allOf!) {
if (this.hasDescendant(ref as RJSFSchema, property_name)) {
return this.getDescendant(ref as RJSFSchema, property_name);
}
}
}
throw new Error("property not found or Jsonschema format not implemented");
}
public pathExists = (resource: RJSFSchema, path: string): boolean => {
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return this.hasDescendant(resource, path);
}
return this.hasDescendant(resource, path.substring(0, pointFirstPosition))
&& this.pathExists(
this.getDescendant(resource, path.substring(0, pointFirstPosition)),
path.substring(pointFirstPosition + 1)
);
}
public getPropertyByPath = (resource: RJSFSchema, path: string): RJSFSchema => {
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return this.getDescendant(resource, path);
}
return this.getPropertyByPath(
this.getDescendant(
resource,
path.substring(0, pointFirstPosition)
),
path.substring(pointFirstPosition + 1) path.substring(pointFirstPosition + 1)
); );
}
function get_property_by_path(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): RJSFSchema {
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return get_descendant(rawSchemas, resource, path);
} }
return get_property_by_path(
rawSchemas,
get_descendant(
rawSchemas,
resource,
path.substring(0, pointFirstPosition)
),
path.substring(pointFirstPosition + 1)
);
} }

View File

@@ -0,0 +1,184 @@
import TextField from "@mui/material/TextField";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import Autocomplete from "@mui/material/Autocomplete";
import { FirmContext } from "../../../contexts/FirmContext";
import { Fragment, useContext } from "react";
import { Box, Grid2, styled } from "@mui/material";
import Stack from "@mui/material/Stack";
import dayjs from "dayjs";
export type FilterField = {
name: string
label: string
type: string
}
type FilterFormProps = {
values: {[field_name: string]: { [operator: string]: string }}
fields: FilterField[]
onChange: (value: any) => void
}
const FilterForm = (props: FilterFormProps) => {
const { fields, values, onChange } = props;
let currentValue = values
const formField = fields.filter(f => f.name != "search").map((f, index) =>
<Fragment key={`${f.name}-${index}`} >
{ f.name == "created_at" && <Box width="100%" /> }
<Grid2 size={6}>
<FilterFormField
field={f}
value={values.hasOwnProperty(f.name) ? values[f.name] : {}}
onChange={(value) => {
for (const op in value) {
if (value[op] == null || value[op] == "") {
if (currentValue.hasOwnProperty(f.name)) {
if (currentValue[f.name].hasOwnProperty(op)) {
delete currentValue[f.name][op];
}
if (Object.entries(currentValue[f.name]).length == 0) {
delete currentValue[f.name];
}
}
} else {
if (! currentValue.hasOwnProperty(f.name)) {
currentValue[f.name] = {};
}
currentValue[f.name][op] = value[op];
}
}
onChange(currentValue);
}}
/>
</Grid2>
</Fragment>
);
return (
<form>
<Grid2 container spacing={2}>
{formField}
</Grid2>
</form>
);
}
type OperatorValue = { [operator: string]: string }
type FilterFormFieldProps = {
field: FilterField
value: OperatorValue
onChange: (value: { [operator: string]: string | null }) => void
}
const FilterFormField = (props: FilterFormFieldProps) => {
const { field, value, onChange } = props;
if (field.type == "string") {
const defaultValue = value.hasOwnProperty('ilike') ? value['ilike'] : undefined
return (
<TextField
name={field.name}
label={field.label}
defaultValue={defaultValue}
fullWidth={true}
onChange={(event) => onChange({"ilike": event.target.value})}
/>
)
} else if (field.type == "dateTime") {
return (
<FilterFieldDateRange field={field} value={value} onChange={onChange} />
)
} else if (field.type == "author") {
return (
<FilterFieldAuthor field={field} value={value} onChange={onChange} />
);
} else if (field.type.slice(0, 4) == "enum") {
const values = field.type.slice(5,-1).split("|");
const defaultValue = value.hasOwnProperty("in") ? [{
value: value["in"],
label: value["in"]
}] : undefined;
return (
<Autocomplete
multiple
options={values.map(opt => {
return {
value: opt,
label: opt
}
})}
onChange={(event, value) => onChange({ "in": value.map(v => v.value).join(",") })}
getOptionLabel={(option) => option.label}
defaultValue={defaultValue}
renderInput={(params) => (
<TextField
{...params}
label={field.label}
/>
)}
/>
)
}
throw("Unsupported field filter type");
}
type FilterFieldAuthorProp = FilterFormFieldProps
const FilterFieldAuthor = (props: FilterFieldAuthorProp) => {
const { field, onChange } = props;
const { partnerMap } = useContext(FirmContext)
if (partnerMap == undefined) {
throw "Can't use author filter outside of the context of a firm";
}
let options = []
for(let key of Array.from(partnerMap.keys()) ) {
options.push({
id: key,
label: partnerMap.get(key)
});
}
return (
<Autocomplete
multiple
renderInput={(params) => <TextField {...params} label={field.label} />}
options={options}
onChange={(event, value) => onChange({ "in": value.length == 0 ? null : value.map(v => v.id).join(",") })}
/>
)
}
const FilterFieldDateRange = (props: FilterFormFieldProps) => {
const { field, value, onChange } = props;
const defaultAfterValue = value.hasOwnProperty('gte') ? dayjs(value['gte']) : undefined;
const defaultBeforeValue = value.hasOwnProperty('lte') ? dayjs(value['lte']) : undefined;
return (
<Stack direction="row">
<DateTimePicker
name={field.name}
label={`${field.label} After:`}
slotProps={{ textField: { fullWidth: true }, field: { clearable: true }}}
defaultValue={defaultAfterValue}
onChange={(value) => onChange({'gte': value === null ? null : value.toJSON()})}
/>
<DateTimePicker
name={field.name}
label={`${field.label} Before:`}
slotProps={{ textField: { fullWidth: true }, field: { clearable: true } }}
defaultValue={defaultBeforeValue}
onChange={(value) => onChange({'lte': value === null ? null : value.toJSON()})}
/>
</Stack>
);
}
export default FilterForm;

View File

@@ -0,0 +1,4 @@
export const Error404Page = () => {
return <h2>EROR NO FUND</h2>
};

View File

@@ -24,9 +24,11 @@ export const ContractRoutes = () => {
const ListContract = () => { const ListContract = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "status", column: { width: 160 }},
{ field: "updated_at", column: { width: 160 }},
]; ];
return <List<Contract> resource={`contracts`} columns={columns} /> return <List<Contract> resource={`contracts`} schemaName={"Contract"} columns={columns} />
} }
const EditContract = () => { const EditContract = () => {

View File

@@ -1,6 +1,9 @@
import { Navigate, Route, Routes, useParams } from "react-router"; import dayjs from "dayjs";
import { CircularProgress } from "@mui/material";
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { Navigate, Route, Routes, useParams } from "react-router";
import { Box, Button, CircularProgress, Container, DialogContent, Modal, Paper } from "@mui/material";
import Stack from "@mui/material/Stack";
import PreviewIcon from '@mui/icons-material/Preview';
import { useOne, useTranslation } from "@refinedev/core"; import { useOne, useTranslation } from "@refinedev/core";
import { BaseForm } from "../../lib/crud/components/base-form"; import { BaseForm } from "../../lib/crud/components/base-form";
import { ForeignKeyReference, ForeignKeySchema } from "../../lib/crud/components/widgets/foreign-key"; import { ForeignKeyReference, ForeignKeySchema } from "../../lib/crud/components/widgets/foreign-key";
@@ -10,7 +13,6 @@ import List from "./base-page/List";
import Edit from "./base-page/Edit"; import Edit from "./base-page/Edit";
import New from "./base-page/New"; import New from "./base-page/New";
import { Contract } from "./ContractRoutes"; import { Contract } from "./ContractRoutes";
import dayjs from "dayjs";
type Draft = { type Draft = {
id: string, id: string,
@@ -29,9 +31,11 @@ export const DraftRoutes = () => {
const ListDraft = () => { const ListDraft = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "status", column: { width: 160 }},
{ field: "updated_at", column: { width: 160 }},
]; ];
return <List<Draft> resource={`contracts/drafts`} columns={columns} /> return <List<Draft> resource={`contracts/drafts`} columns={columns} schemaName={"Contract"} />
} }
const EditDraft = () => { const EditDraft = () => {
@@ -48,7 +52,7 @@ const EditDraft = () => {
return <CircularProgress /> return <CircularProgress />
} }
if (!data?.data) { if (record_id == undefined || !data?.data) {
return <Navigate to="../" /> return <Navigate to="../" />
} }
@@ -61,12 +65,56 @@ const EditDraft = () => {
return ( return (
<> <>
<DraftPreview resourceBasePath={resourceBasePath} recordId={record_id}/>
<Edit<Draft> resource={"contracts/drafts"} schemaName={"ContractDraft"} uiSchema={uiSchema} /> <Edit<Draft> resource={"contracts/drafts"} schemaName={"ContractDraft"} uiSchema={uiSchema} />
<ContractCreate draft={draft}></ContractCreate> <ContractCreate draft={draft}></ContractCreate>
</> </>
) )
} }
const DraftPreview = (props: {resourceBasePath: string, recordId: string}) => {
const { resourceBasePath, recordId } = props
const [openPreviewModal, setOpenPreviewModal] = React.useState(false);
return (
<>
<Button variant="outlined" onClick={() => setOpenPreviewModal(true)} color="primary" >
<PreviewIcon />Preview
</Button>
<Modal
open={openPreviewModal}
onClose={() => setOpenPreviewModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<DialogContent>
<Container>
<Paper>
<Stack
direction={"row"}
spacing={2}
sx={{
justifyContent: "center",
alignItems: "center",
}}
>
<Box padding={"45px"}>
<iframe
src={`/api/v1/${resourceBasePath}/contracts/preview/draft/${recordId}`}
width="675px"
height="955px"
style={{ backgroundColor: "white", border: "1px solid black" }}
/>
</Box>
</Stack>
</Paper>
</Container>
</DialogContent>
</Modal>
</>
)
}
const ContractCreate = (props: { draft: any}) => { const ContractCreate = (props: { draft: any}) => {
const { translate: t } = useTranslation(); const { translate: t } = useTranslation();
const { draft } = props; const { draft } = props;

View File

@@ -1,5 +1,4 @@
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useTranslation } from "@refinedev/core";
import List from "./base-page/List"; import List from "./base-page/List";
import Edit from "./base-page/Edit"; import Edit from "./base-page/Edit";
import New from "./base-page/New"; import New from "./base-page/New";
@@ -22,12 +21,12 @@ export const EntityRoutes = () => {
} }
const ListEntity = () => { const ListEntity = () => {
const { translate: t } = useTranslation();
const columns = [ const columns = [
{ field: "entity_data", headerName: t("schemas.type"), width: 110, valueFormatter: ({ type }: {type: string}) => type }, { field: "entity_data.type", column: { width: 110 }},
{ field: "label", headerName: t("schemas.label"), flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "updated_at", column: { width: 160 }},
]; ];
return <List<Entity> resource={`entities`} columns={columns} /> return <List<Entity> resource={`entities`} schemaName={"Entity"} columns={columns} />
} }
const EditEntity = () => { const EditEntity = () => {

View File

@@ -21,9 +21,10 @@ export const ProvisionRoutes = () => {
const ListProvision = () => { const ListProvision = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "updated_at", column: { width: 160 }},
]; ];
return <List<Provision> resource={`templates/provisions`} columns={columns} /> return <List<Provision> resource={`templates/provisions`} schemaName={"ProvisionTemplate"} columns={columns} />
} }
const EditProvision = () => { const EditProvision = () => {

View File

@@ -20,9 +20,10 @@ export const TemplateRoutes = () => {
const ListTemplate = () => { const ListTemplate = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "updated_at", column: { width: 160 }},
]; ];
return <List<Template> resource={`templates/contracts`} columns={columns} /> return <List<Template> resource={`templates/contracts`} schemaName={"ContractTemplate"} columns={columns} />
} }
const EditTemplate = () => { const EditTemplate = () => {

View File

@@ -1,6 +1,6 @@
import { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
import { useContext } from "react"; import { useContext } from "react";
import { useParams, Navigate } from "react-router"; import { useParams, Navigate, Link } from "react-router";
import { Button, CircularProgress } from "@mui/material"; import { Button, CircularProgress } from "@mui/material";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
@@ -38,9 +38,16 @@ const Edit = <T,>(props: EditProps) => {
return <Navigate to="../" /> return <Navigate to="../" />
} }
if (query.error?.status == 404) {
throw query.error
}
const record = query.data.data; const record = query.data.data;
return ( return (
<> <>
<Link to={"../"} >
<Button>{t("buttons.list")}</Button>
</Link>
<h2>{record.label}</h2> <h2>{record.label}</h2>
<Cartouche record={record}/> <Cartouche record={record}/>
<CrudForm <CrudForm

View File

@@ -1,37 +1,62 @@
import { UiSchema } from "@rjsf/utils";
import { useTranslation } from "@refinedev/core";
import { List as RefineList, useDataGrid } from "@refinedev/mui";
import { DataGrid, GridColDef, GridValidRowModel } from "@mui/x-data-grid";
import { Link, useNavigate } from "react-router"
import React, { useContext } from "react"; import React, { useContext } from "react";
import { Link, useNavigate } from "react-router"
import { UiSchema } from "@rjsf/utils";
import { CrudFilter, useTranslation } from "@refinedev/core";
import { List as RefineList, useDataGrid } from "@refinedev/mui";
import { Button } from "@mui/material"; import { Button } from "@mui/material";
import { GridColDef, GridValidRowModel } from "@mui/x-data-grid";
import { FirmContext } from "../../../contexts/FirmContext"; import { FirmContext } from "../../../contexts/FirmContext";
import CrudList from "../../../lib/crud/components/crud-list";
import CrudFilters, { OnChangeValue } from "../../../lib/crud/components/crud-filters";
import { CrudOperators } from "@refinedev/core/src/contexts/data/types";
type ListProps<T extends GridValidRowModel> = { type ListProps = {
resource: string, resource: string,
columns: GridColDef<T>[], columns: ColumnDefinition[],
schemaName?: string, schemaName: string,
uiSchema?: UiSchema, uiSchema?: UiSchema,
} }
const List = <T extends GridValidRowModel>(props: ListProps<T>) => { type ColumnDefinition = {
const { resource, columns } = props; field: string,
column: Partial<GridColDef>,
hide?: boolean
}
const List = <T extends GridValidRowModel>(props: ListProps) => {
const { resource, columns, schemaName } = props;
const { translate: t } = useTranslation(); const { translate: t } = useTranslation();
const { currentFirm } = useContext(FirmContext); const { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}` const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
const { dataGridProps } = useDataGrid<T>({ const { dataGridProps, tableQuery, setFilters } = useDataGrid<T>({
resource: `${resourceBasePath}/${resource}`, resource: `${resourceBasePath}/${resource}`,
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const cols = React.useMemo<GridColDef<T>[]>( if (tableQuery.error?.status == 404) {
() => columns, throw tableQuery.error
[], }
);
const handleRowClick = (params: any, event: any) => { const onFilterChange = (value: OnChangeValue) => {
navigate(`edit/${params.id}`) let newFilters: CrudFilter[] = []
if (value.search != null) {
newFilters.push({
field: "search",
operator: "eq",
value: value.search
})
}
for (const filterName in value.filters) {
for (const operator in value.filters[filterName]) {
newFilters.push({
field: filterName,
operator: operator as Exclude<CrudOperators, "or" | "and">,
value: value.filters[filterName][operator]
})
}
}
setFilters(newFilters);
} }
return ( return (
@@ -39,11 +64,17 @@ const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
<Link to={"create"} > <Link to={"create"} >
<Button>{t("buttons.create")}</Button> <Button>{t("buttons.create")}</Button>
</Link> </Link>
<DataGrid <CrudFilters
{...dataGridProps} resourceName={schemaName}
columns={cols} resourcePath={`${resourceBasePath}/${resource}`}
onRowClick={handleRowClick} onChange={onFilterChange}
pageSizeOptions={[10, 15, 20, 50, 100]} />
<CrudList
dataGridProps={dataGridProps}
onRowClick={(params: any) => { navigate(`edit/${params.id}`) }}
schemaName={schemaName}
resourceBasePath={resourceBasePath}
columnDefinitions={columns}
/> />
</RefineList> </RefineList>
) )

View File

@@ -1,7 +1,7 @@
import { Route, Routes, Link } from "react-router"; import { Route, Routes, Link } from "react-router";
import React, { useContext } from "react"; import React from "react";
import { useForm, useOne, useTranslation } from "@refinedev/core"; import { useForm, useTranslation } from "@refinedev/core";
import { FirmContext, FirmContextProvider } from "../../contexts/FirmContext"; import { FirmContextProvider } from "../../contexts/FirmContext";
import { Header } from "../../components"; import { Header } from "../../components";
import { CrudForm } from "../../lib/crud/components/crud-form"; import { CrudForm } from "../../lib/crud/components/crud-form";
import { IFirm } from "../../interfaces"; import { IFirm } from "../../interfaces";
@@ -10,22 +10,26 @@ import { ContractRoutes } from "./ContractRoutes";
import { DraftRoutes } from "./DraftRoutes"; import { DraftRoutes } from "./DraftRoutes";
import { TemplateRoutes } from "./TemplateRoutes"; import { TemplateRoutes } from "./TemplateRoutes";
import { ProvisionRoutes } from "./ProvisionRoutes"; import { ProvisionRoutes } from "./ProvisionRoutes";
import { ErrorBoundary } from "react-error-boundary";
import { Error404Page } from "../ErrorPage";
export const FirmRoutes = () => { export const FirmRoutes = () => {
return ( return (
<Routes> <Routes>
<Route path="/:instance/:firm/*" element={ <Route path="/:instance/:firm/*" element={
<FirmContextProvider> <ErrorBoundary fallback={<><Header /><Error404Page /></>} >
<Header /> <FirmContextProvider>
<Routes> <Header />
<Route index element={ <FirmHome /> } /> <Routes>
<Route path="/entities/*" element={ <EntityRoutes /> } /> <Route index element={ <FirmHome /> } />
<Route path="/provisions/*" element={ <ProvisionRoutes /> } /> <Route path="/entities/*" element={ <EntityRoutes /> } />
<Route path="/templates/*" element={ <TemplateRoutes /> } /> <Route path="/provisions/*" element={ <ProvisionRoutes /> } />
<Route path="/drafts/*" element={ <DraftRoutes /> } /> <Route path="/templates/*" element={ <TemplateRoutes /> } />
<Route path="/contracts/*" element={ <ContractRoutes /> } /> <Route path="/drafts/*" element={ <DraftRoutes /> } />
</Routes> <Route path="/contracts/*" element={ <ContractRoutes /> } />
</FirmContextProvider> </Routes>
</FirmContextProvider>
</ErrorBoundary>
} /> } />
</Routes> </Routes>
); );
@@ -51,7 +55,7 @@ type FirmInitFormPros = {
currentFirm: IFirm currentFirm: IFirm
} }
const FirmInitForm = (props: FirmInitFormPros) => { export const FirmInitForm = (props: FirmInitFormPros) => {
const { currentFirm } = props; const { currentFirm } = props;
const { translate: t } = useTranslation(); const { translate: t } = useTranslation();
const resourceBasePath = `firm` const resourceBasePath = `firm`

View File

@@ -12,7 +12,7 @@ const DEFAULT_LOGIN_REDIRECT = "/hub"
const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async ({ providerName, email, password }) => { login: async ({ providerName, email, password }) => {
const to_param = findGetParameter("to"); const to_param = findGetParameter("to");
const redirect = to_param === null ? DEFAULT_LOGIN_REDIRECT : to_param const redirect = to_param === null ? getLoginRedirect() : to_param
if (providerName) { if (providerName) {
let scope = {}; let scope = {};
if (providerName === "google") { if (providerName === "google") {
@@ -61,18 +61,22 @@ const authProvider: AuthProvider = {
const response = await fetch(`${API_URL}/hub/auth/logout`, { method: "POST" }); const response = await fetch(`${API_URL}/hub/auth/logout`, { method: "POST" });
if (response.status == 204 || response.status == 401) { if (response.status == 204 || response.status == 401) {
forget_user(); forget_user();
return { return { success: true };
success: true,
redirectTo: "/",
};
} }
return { success: false }; return { success: false };
}, },
check: async () => { check: async () => {
if (get_user() == null) { const user = get_user();
if (user == null || isEmpty(user)) {
const user_data = await get_me();
if (user_data) {
store_user(user_data)
return { authenticated: true }
}
return { return {
authenticated: false, authenticated: false,
redirectTo: "/login",
logout: true logout: true
} }
} }
@@ -84,11 +88,7 @@ const authProvider: AuthProvider = {
return user; return user;
} }
const response = await fetch(`${API_URL}/hub/users/me`); const user_data = get_me()
if (response.status < 200 || response.status > 299) {
return null;
}
const user_data = await response.json();
store_user(user_data) store_user(user_data)
return user_data; return user_data;
@@ -154,9 +154,8 @@ const authProvider: AuthProvider = {
if (error?.status === 401) { if (error?.status === 401) {
forget_user(); forget_user();
return { return {
redirectTo: "/login",
logout: true,
error: { message: "Authentication required" }, error: { message: "Authentication required" },
logout: true,
} as OnErrorResponse; } as OnErrorResponse;
} }
else if (error?.status === 403) { else if (error?.status === 403) {
@@ -170,6 +169,15 @@ const authProvider: AuthProvider = {
}, },
}; };
async function get_me() {
const response = await fetch(`${API_URL}/hub/users/me`);
if (response.status < 200 || response.status > 299) {
return null;
}
const user_data = await response.json();
return user_data
}
function store_user(user: any) { function store_user(user: any) {
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(user)); localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(user));
} }
@@ -200,4 +208,11 @@ function findGetParameter(parameterName: string) {
return result; return result;
} }
function getLoginRedirect() {
if (location.pathname == "/login") {
return DEFAULT_LOGIN_REDIRECT
}
return location.pathname + location.search;
}
export default authProvider; export default authProvider;

View File

@@ -2,6 +2,18 @@ import type { DataProvider, HttpError } from "@refinedev/core";
const API_URL = "/api/v1"; const API_URL = "/api/v1";
function handleErrors(response: { status: number, statusText: string }) {
let message = response.statusText
if (response.status == 405) {
message = "Resource is not ready";
}
const error: HttpError = {
message: message,
statusCode: response.status,
};
return Promise.reject(error);
}
const dataProvider: DataProvider = { const dataProvider: DataProvider = {
getOne: async ({ resource, id, meta }) => { getOne: async ({ resource, id, meta }) => {
if (id === "") { if (id === "") {
@@ -9,14 +21,7 @@ const dataProvider: DataProvider = {
} }
const response = await fetch(`${API_URL}/${resource}/${id}`); const response = await fetch(`${API_URL}/${resource}/${id}`);
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
if (response.status == 405) { return handleErrors(response);
const error: HttpError = {
message: "Resource is not ready",
statusCode: 405,
};
return Promise.reject(error);
}
throw response;
} }
const data = await response.json(); const data = await response.json();
@@ -35,17 +40,10 @@ const dataProvider: DataProvider = {
}); });
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
if (response.status == 405) { return handleErrors(response);
const error: HttpError = {
message: "Resource is not ready",
statusCode: 405,
};
return Promise.reject(error);
}
throw response;
} }
const data = await response.json();
const data = await response.json();
return { data }; return { data };
}, },
getList: async ({ resource, pagination, filters, sorters, meta }) => { getList: async ({ resource, pagination, filters, sorters, meta }) => {
@@ -63,8 +61,12 @@ const dataProvider: DataProvider = {
if (filters && filters.length > 0) { if (filters && filters.length > 0) {
filters.forEach((filter) => { filters.forEach((filter) => {
if ("field" in filter && filter.value && filter.operator === "contains") { if ("field" in filter) {
params.append(filter.field + "__ilike", filter.value); if (filter.field == "search") {
params.append("search", filter.value);
} else {
params.append(`${filter.field.replace(".", "__")}__${filter.operator}`, filter.value);
}
} }
}); });
} }
@@ -72,14 +74,7 @@ const dataProvider: DataProvider = {
const response = await fetch(`${API_URL}/${resource}/?${params.toString()}`); const response = await fetch(`${API_URL}/${resource}/?${params.toString()}`);
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
if (response.status == 405) { return handleErrors(response);
const error: HttpError = {
message: "Resource is not ready",
statusCode: 405,
};
return Promise.reject(error);
}
throw response;
} }
const data = await response.json(); const data = await response.json();
@@ -105,18 +100,10 @@ const dataProvider: DataProvider = {
}); });
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
if (response.status == 405) { return handleErrors(response);
const error: HttpError = {
message: "Resource is not ready",
statusCode: 405,
};
return Promise.reject(error);
}
throw response;
} }
const data = await response.json(); const data = await response.json();
return { data }; return { data };
}, },
deleteOne: async ({ resource, id, variables, meta }) => { deleteOne: async ({ resource, id, variables, meta }) => {
@@ -125,21 +112,11 @@ const dataProvider: DataProvider = {
}); });
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
if (response.status == 405) { return handleErrors(response);
const error: HttpError = {
message: "Resource is not ready",
statusCode: 405,
};
return Promise.reject(error);
}
throw response;
} }
const data = await response.json(); const data = await response.json();
return { data };
return {
data
};
}, },
getApiUrl: () => API_URL, getApiUrl: () => API_URL,
// Optional methods: // Optional methods: