Compare commits

..

31 Commits

Author SHA1 Message Date
2ff15cdef4 Refactoring schema fetching in hooks 2025-05-14 19:08:15 +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
39 changed files with 1921 additions and 980 deletions

View File

@@ -210,15 +210,15 @@ def replace_variables_in_value(variables, value: str):
class ContractDraftFilters(FilterSchema):
status: Optional[str] = None
status__in: Optional[list[str]] = None
class Constants(Filter.Constants):
model = ContractDraft
search_model_fields = ["label", "status"]
search_model_fields = ["label"]
class ContractFilters(FilterSchema):
status: Optional[str] = None
status__in: Optional[list[str]] = None
class Constants(Filter.Constants):
model = Contract
search_model_fields = ["label", "status"]
search_model_fields = ["label"]

View File

@@ -18,9 +18,15 @@ class Registry:
self.current_firm = CurrentFirm.get_current(self.db)
async def set_user(self, user):
def check_user(self, user):
for firm in user.firms:
if firm.instance == self.instance and firm.firm == self.firm:
return True
raise PermissionError
async def set_user(self, user):
self.check_user(user)
partner = await Partner.get_by_user_id(self.db, user.id)
partner_entity = await Entity.get(self.db, partner.entity_id)
self.user = user
@@ -33,26 +39,32 @@ class Registry:
async def get_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client)) -> Registry:
registry = Registry(db_client, instance, firm)
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
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:
await registry.set_user(user)
registry.check_user(user)
except PermissionError:
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
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)
if await registry.current_firm is not None:
raise HTTPException(status_code=409, detail="Firm configuration already exists")
try:
await registry.set_user(user)
registry.check_user(user)
except PermissionError:
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

View File

@@ -155,4 +155,11 @@ class Filter(BaseFilterModel):
class FilterSchema(Filter):
label__ilike: Optional[str] = None
search: Optional[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

@@ -5,7 +5,9 @@ from pydantic import BaseModel, Field
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
def from_model(cls, model):

View File

@@ -1,7 +1,7 @@
from typing import Any
from typing import Any, Optional
from beanie import PydanticObjectId
from pydantic import Field
from pydantic import Field, BaseModel
from firm.core.models import CrudDocument, CrudDocumentConfig
from firm.core.schemas import Writer, Reader
@@ -32,7 +32,8 @@ class CurrentFirm(CrudDocument):
return cls.model_validate(document)
class CurrentFirmSchemaRead(Reader):
class CurrentFirmSchemaRead(BaseModel):
id: Optional[PydanticObjectId]
entity: EntityRead
partner: EntityRead
partner_list: list[EntityRead]

View File

@@ -31,14 +31,22 @@ async def create(schema: CurrentFirmSchemaCreate, reg=Depends(get_uninitialized_
corporation_schema.entity_data.employees.append(Employee(entity_id=owner_entity.id, position=schema.position))
corp = await Entity.create(reg.db, corporation_schema)
document = await CurrentFirm.create(reg.db, CurrentFirm(
firm = await CurrentFirm.create(reg.db, CurrentFirm(
instance=reg.instance,
firm=reg.firm,
entity_id=corp.id,
primary_color=schema.primary_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")
async def update(schema: CurrentFirmSchemaUpdate, reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead:

View File

@@ -83,7 +83,7 @@ class Entity(CrudDocument):
class EntityDataFilter(Filter):
type__in: Optional[list[EntityTypeEnum]] = None
type__in: Optional[list[str]] = None
class Constants(Filter.Constants):
model = EntityType

View File

@@ -42,7 +42,7 @@ class ProvisionTemplateReference(BaseModel):
provision_template_id: PydanticObjectId = ForeignKey(
"templates/provisions",
"TemplateProvision",
"ProvisionTemplate",
['title', 'body'],
props={"parametrized": True},
title="Template de clause"

View File

@@ -67,7 +67,7 @@ class AuthenticationBackendMe(AuthenticationBackend):
class CookieTransportOauth(CookieTransport):
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)
@staticmethod

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import { IFirm } from "../interfaces";
import { useParams } from "react-router";
import { useOne } from "@refinedev/core";
import { CircularProgress } from "@mui/material";
import { FirmInitForm } from "../pages/firm";
import { Header } from "../components";
type FirmContextType = {
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});
if (instance === undefined || firm === undefined) {
return "Error"
throw({statusCode: 400});
}
const currentFirm: IFirm = { instance, firm }
if (isLoading) {
return <CircularProgress />
}
let value: FirmContextType = {
currentFirm: {instance, firm}
if (isError && error) {
if (error.statusCode == 405) {
return <><Header /><FirmInitForm currentFirm={currentFirm} /></>
}
if (!isError || error?.statusCode != 405) {
value.currentFirm.entity = data?.data.entity;
value.partnerMap = new Map(data?.data.partner_list.map((item: any) => [item.id, item.label]));
if (error.statusCode == 404) {
throw error;
}
}
currentFirm.entity = data?.data.entity;
let value: FirmContextType = {
currentFirm: currentFirm,
partnerMap: new Map(data?.data.partner_list.map((item: any) => [item.id, item.label])),
}
return (

View File

@@ -7,6 +7,8 @@ import ArrayFieldTemplate from "./templates/ArrayFieldTemplate"
import ArrayFieldItemTemplate from "./templates/ArrayFieldItemTemplate";
import { ResourceContext } from "../contexts/ResourceContext";
import { ReactNode } from "react";
import { ParametersContextProvider } from "../contexts/parameters-context";
import CrudArrayField from "./fields/crud-array-field";
type BaseFormProps = {
schema: RJSFSchema,
@@ -19,11 +21,12 @@ type BaseFormProps = {
}
export const customWidgets: RegistryWidgetsType = {
TextWidget: CrudTextWidget
TextWidget: CrudTextWidget,
};
export const customFields: RegistryFieldsType = {
AnyOfField: UnionEnumField
AnyOfField: UnionEnumField,
ArrayField: CrudArrayField
}
const customTemplates = {
@@ -36,6 +39,7 @@ export const BaseForm: React.FC<BaseFormProps> = (props) => {
return (
<ResourceContext.Provider value={{basePath: resourceBasePath}} >
<ParametersContextProvider>
<Form
schema={schema}
uiSchema={uiSchema === undefined ? {} : uiSchema}
@@ -49,6 +53,7 @@ export const BaseForm: React.FC<BaseFormProps> = (props) => {
onChange={(e, id) => onChange != undefined && onChange(e.formData)}
children={children}
/>
</ParametersContextProvider>
</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 { useForm } from "@refinedev/core";
import { UiSchema } from "@rjsf/utils";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { BaseForm } from "./base-form";
import { useResourceSchema } from "../hook";
type CrudFormProps = {
schemaName: string,
@@ -18,31 +17,8 @@ type CrudFormProps = {
export const CrudForm: React.FC<CrudFormProps> = (props) => {
const { schemaName, uiSchema, record, resourceBasePath, defaultValue, children, onSubmit=(data: any) => {}, card=false } = props;
const [schema, setSchema] = useState({});
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();
}, []);
const type = record === undefined ? "create" : card ? "card" : "update"
const { schema, schemaLoading } = useResourceSchema(schemaName, type);
if(schemaLoading) {
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,
} from '@rjsf/utils';
import { CrudTextRJSFSchema } from "../widgets/crud-text-widget";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
/** The `ArrayFieldTemplate` component is the template used to render all items in an array.
@@ -45,7 +44,7 @@ export default function ArrayFieldTemplate<
} = registry.templates;
let gridSize = 12;
let numbered = false
let numbered = false;
if (schema.props) {
if (schema.props.hasOwnProperty("items_per_row")) {
gridSize = gridSize / schema.props.items_per_row;
@@ -74,7 +73,7 @@ export default function ArrayFieldTemplate<
registry={registry}
/>
<Grid2 container justifyContent='flex-start'>
{items &&
{ items &&
items.map(({ key, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>, index) => (
<Grid2 key={key} size={gridSize} >
<Grid2 container sx={{alignItems: "center"}} >
@@ -82,9 +81,10 @@ export default function ArrayFieldTemplate<
<Grid2 size={numbered ? 11.5 : 12} ><ArrayFieldItemTemplate key={key} {...itemProps} /></Grid2>
</Grid2>
</Grid2>
))}
))
}
</Grid2>
{canAdd && (
{ canAdd && (
<Grid2 container justifyContent='flex-end'>
<Grid2>
<Box mt={2}>

View File

@@ -1,6 +1,6 @@
import React from "react";
import { getDefaultRegistry } from "@rjsf/core";
import { FormContextType, getTemplate, RJSFSchema, WidgetProps } from "@rjsf/utils";
import { FormContextType, RJSFSchema, WidgetProps } from "@rjsf/utils";
import Typography from "@mui/material/Typography";
import ForeignKeyWidget from "./foreign-key";

View File

@@ -9,6 +9,7 @@ import React, { useState, useEffect, useContext, Fragment } from "react";
import { useForm, useList, useOne } from "@refinedev/core";
import { ResourceContext } from "../../contexts/ResourceContext";
import { CrudForm } from "../crud-form";
import { ParametersContext } from "../../contexts/parameters-context";
export type ForeignKeyReference = {
resource: string,
@@ -21,6 +22,7 @@ export type ForeignKeySchema = RJSFSchema & {
foreignKey?: {
reference: ForeignKeyReference
}
props? : 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>(
props: WidgetProps<T, S, F>
) => {
if (props.schema.foreignKey === undefined) {
const { onChange, label, fieldId, schema } = props;
if (schema.foreignKey === undefined) {
return;
}
const { onChange, label } = props
const [openFormModal, setOpenFormModal] = useState(false);
const [searchString, setSearchString] = useState<string>("");
@@ -55,12 +57,19 @@ const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema
return () => clearTimeout(handler);
}, [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 { data, isLoading } = useList({
resource: `${basePath}/${resource}`,
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" }],
});
@@ -104,13 +113,13 @@ const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema
>
<DialogContent>
<FormContainerNew
schemaName={schema}
schemaName={fkSchema}
resourceBasePath={basePath}
resource={resource}
uiSchema={{}}
onSuccess={(data: any) => {
setOpenFormModal(false)
onChange(data.data.id);
onChange(data.id);
}}
/>
</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>(
props: WidgetProps<T, S, F> & { onClear: () => void }
) => {
const { onClear, value } = props;
const { onClear, value, schema, id: fieldId } = props;
const [openFormModal, setOpenFormModal] = React.useState(false);
if (props.schema.foreignKey === undefined) {
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 { data, isLoading } = useOne({
const { data, isLoading, isSuccess } = useOne({
resource: `${basePath}/${resource}`,
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) {
return <CircularProgress />
}
const record = data.data;
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={{
input: {
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
open={openFormModal}
onClose={() => setOpenFormModal(false)}
@@ -167,7 +185,7 @@ const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F e
>
<DialogContent>
<FormContainerEdit
schemaName={schema}
schemaName={fkSchema}
resourceBasePath={basePath}
resource={resource}
uiSchema={{}}
@@ -233,7 +251,8 @@ const FormContainerNew = (props: FormContainerProps) => {
const { schemaName, resourceBasePath, resource, uiSchema = {}, onSuccess } = props;
const { onFinish } = useForm({
resource: `${resourceBasePath}/${resource}`,
action: "create"
action: "create",
onMutationSuccess: data => onSuccess(data.data)
});
return (
@@ -242,25 +261,14 @@ const FormContainerNew = (props: FormContainerProps) => {
schemaName={schemaName}
uiSchema={uiSchema}
resourceBasePath={resourceBasePath}
onSubmit={(data:any) => {
onFinish(data);
onSuccess(data);
}} />
onSubmit={(data:any) => { onFinish(data);}}
/>
</Box>
)
}
const Preview = (props: {id: string, resource: string, basePath: string, displayedFields: [string]}) => {
const { basePath, resource, id, displayedFields } = props
const { data, isLoading } = useOne({
resource: `${basePath}/${resource}`,
id
});
if (isLoading || data === undefined) {
return <CircularProgress />
}
const Preview = (props: {record: any, displayedFields: [string]}) => {
const { record, displayedFields } = props
return (
<Grid2 container spacing={2}>
@@ -268,10 +276,29 @@ const Preview = (props: {id: string, resource: string, basePath: string, display
return (
<Fragment key={index}>
<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>
)
})}
</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 = {
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 == "update") {
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

@@ -2,6 +2,7 @@ import { RJSFSchema } from '@rjsf/utils';
import i18n from '../../../i18n'
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";
@@ -67,20 +68,140 @@ export const jsonschemaProvider = {
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> => {
return buildResource(await getJsonschema(), resourceName)
}
const getColumns = async (resourceName: string): Promise<GridColDef[]> => {
return buildColumns(await getJsonschema(), resourceName)
}
function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: string|undefined = undefined): GridColDef[] {
if (rawSchemas.components.schemas[resourceName] === undefined) {
throw new Error(`Resource "${resourceName}" not found in schema.`);
const getFilters = async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
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
}
}
const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
let resource = rawSchemas.components.schemas[resourceName];
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];
@@ -120,20 +241,32 @@ function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: str
const subresourceName = get_reference_name(prop.items);
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
} else {
let column: GridColDef = {
field: prefix ? `${prefix}.${prop_name}` : prop_name,
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prop_name)}`, prop.title) as string,
valueGetter: (value: any, row: any ) => {
if (prefix === undefined) {
return value;
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 column of prefix.split(".")) {
parent = parent[column];
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);
}
@@ -141,42 +274,32 @@ function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: str
return result;
}
const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => {
return buildResource(await getJsonschema(), resourceName)
}
function convertCamelToSnake(str: string): string {
return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase()
}
function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
if (rawSchemas.components.schemas[resourceName] === undefined) {
throw new Error(`Resource "${resourceName}" not found in schema.`);
}
const shortResourceName = shortenResourceName(resourceName);
const jst = new JsonSchemaTraverser(rawSchemas);
let resource = structuredClone(jst.getResource(resourceName));
const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
let resource = structuredClone(rawSchemas.components.schemas[resourceName]);
resource.components = { schemas: {} };
for (let prop_name in resource.properties) {
let prop = resource.properties[prop_name];
if (is_reference(prop)) {
resolveReference(rawSchemas, resource, prop);
buildReference(rawSchemas, resource, prop);
} else if (is_union(prop)) {
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
for (let i in union) {
if (is_reference(union[i])) {
resolveReference(rawSchemas, resource, union[i]);
buildReference(rawSchemas, resource, union[i]);
}
}
} else if (is_enum(prop)) {
for (let i in prop.allOf) {
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)) {
resolveReference(rawSchemas, resource, prop.items);
buildReference(rawSchemas, resource, prop.items);
}
if (prop.hasOwnProperty("title")) {
@@ -190,16 +313,7 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
return resource;
}
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;
}
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 subresource = buildResource(rawSchemas, subresourceName);
resource.components.schemas[subresourceName] = subresource;
@@ -268,81 +382,157 @@ function get_reference_name(prop: any) {
return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1);
}
function has_descendant(rawSchemas:RJSFSchema, resource: RJSFSchema, property_name: string): boolean {
function convertCamelToSnake(str: string): string {
return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase()
}
function shortenResourceName(resourceName: string) {
return convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
}
function getPropertyI18nLabel(shortResourceName: string, fieldName: string): string {
if (meta_fields.indexOf(fieldName) > -1) {
return i18n.t(`schemas.${convertCamelToSnake(fieldName)}`);
}
const path = `schemas.${shortResourceName}.${convertCamelToSnake(fieldName)}`
return i18n.t(path)
}
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.`);
}
return this.rawSchemas.components.schemas[resourceName]
}
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
}
}
}
if (pathSchema === undefined) {
throw ("Path not found in schema");
}
return pathSchema
}
public hasDescendant = (resource: RJSFSchema, property_name: string): boolean => {
if (is_array(resource)) {
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);
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 has_descendant(rawSchemas, ref as RJSFSchema, property_name)
return this.hasDescendant(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);
return this.hasDescendant(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 {
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!) {
return resource.properties[property_name] as RJSFSchema;
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 = buildResource(rawSchemas, subresourceName);
return get_descendant(rawSchemas, subresource, property_name);
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 (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) {
return get_descendant(rawSchemas, ref as RJSFSchema, property_name);
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 (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) {
return get_descendant(rawSchemas, ref as RJSFSchema, property_name);
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");
}
function path_exists(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): boolean{
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return has_descendant(rawSchemas, resource, path);
}
return has_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition))
&& path_exists(
rawSchemas,
get_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition)),
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)
);
}
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,
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)
);
}
}

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

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

View File

@@ -30,6 +30,8 @@ export const DraftRoutes = () => {
const ListDraft = () => {
const columns = [
{ field: "label", column: { flex: 1 }},
{ field: "status", column: { width: 160 }},
{ field: "updated_at", column: { width: 160 }},
];
return <List<Draft> resource={`contracts/drafts`} columns={columns} schemaName={"Contract"} />
}

View File

@@ -24,7 +24,7 @@ const ListEntity = () => {
const columns = [
{ field: "entity_data.type", column: { width: 110 }},
{ field: "label", column: { flex: 1 }},
{ field: "updated_at", column: { flex: 1 }},
{ field: "updated_at", column: { width: 160 }},
];
return <List<Entity> resource={`entities`} schemaName={"Entity"} columns={columns} />
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import React, { useContext } from "react";
import { Link, useNavigate } from "react-router"
import { UiSchema } from "@rjsf/utils";
import { useTranslation } from "@refinedev/core";
import { CrudFilter, useTranslation } from "@refinedev/core";
import { List as RefineList, useDataGrid } from "@refinedev/mui";
import { DataGrid, GridColDef, GridValidRowModel, GridColumnVisibilityModel } from "@mui/x-data-grid";
import React, { useContext, useEffect, useState } from "react";
import { Button, CircularProgress } from "@mui/material";
import { Button } from "@mui/material";
import { GridColDef, GridValidRowModel } from "@mui/x-data-grid";
import { FirmContext } from "../../../contexts/FirmContext";
import { jsonschemaProvider } from "../../../lib/crud/providers/jsonschema-provider";
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 = {
resource: string,
@@ -15,74 +17,46 @@ type ListProps = {
uiSchema?: UiSchema,
}
type ColumnSchema<T extends GridValidRowModel> = {
columns: GridColDef<T>[],
columnVisibilityModel: GridColumnVisibilityModel
}
type ColumnDefinition = {
field: string,
column: Partial<GridColDef>,
hide?: boolean
}
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
}
}
const List = <T extends GridValidRowModel>(props: ListProps) => {
const { resource, columns, schemaName } = props;
const { translate: t } = useTranslation();
const { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
const { dataGridProps } = useDataGrid<T>({
const { dataGridProps, tableQuery, setFilters } = useDataGrid<T>({
resource: `${resourceBasePath}/${resource}`,
});
const navigate = useNavigate();
const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
const resourceColumns = await jsonschemaProvider.getReadResourceColumns(schemaName)
const definedColumns = computeColumnSchema<T>(columns, resourceColumns)
setColumnSchema(definedColumns);
console.log(resourceColumns);
setSchemaLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
const handleRowClick = (params: any, event: any) => {
navigate(`edit/${params.id}`)
if (tableQuery.error?.status == 404) {
throw tableQuery.error
}
if (schemaLoading || columnSchema === undefined) {
return <CircularProgress />
const onFilterChange = (value: OnChangeValue) => {
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 (
@@ -90,16 +64,17 @@ const List = <T extends GridValidRowModel>(props: ListProps) => {
<Link to={"create"} >
<Button>{t("buttons.create")}</Button>
</Link>
<DataGrid
{...dataGridProps}
columns={columnSchema.columns}
onRowClick={handleRowClick}
pageSizeOptions={[10, 15, 20, 50, 100]}
initialState={{
columns: {
columnVisibilityModel: columnSchema.columnVisibilityModel
}
}}
<CrudFilters
resourceName={schemaName}
resourcePath={`${resourceBasePath}/${resource}`}
onChange={onFilterChange}
/>
<CrudList
dataGridProps={dataGridProps}
onRowClick={(params: any) => { navigate(`edit/${params.id}`) }}
schemaName={schemaName}
resourceBasePath={resourceBasePath}
columnDefinitions={columns}
/>
</RefineList>
)

View File

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

View File

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

View File

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