data-primals-engine 1.1.7-rc1 → 1.1.8-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -14
- package/client/src/App.jsx +55 -49
- package/client/src/App.scss +3 -1
- package/client/src/RelationField.jsx +59 -47
- package/client/src/constants.js +20 -1
- package/client/src/filter.js +18 -0
- package/client/src/translations.js +46 -28
- package/package.json +6 -7
- package/src/engine.js +27 -9
- package/src/middlewares/middleware-mongodb.js +1 -1
- package/src/modules/data.js +169 -0
- package/test/data.integration.test.js +102 -3
package/README.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
# Data Primals Engine
|
|
2
|
+
[](https://github.com/anonympins/data-primals-engine/actions/workflows/node.js.yml)
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
2
7
|
|
|
3
8
|
**data-primals-engine** is a powerful and flexible **Node.js** backend framework designed to accelerate development of complex data-driven applications. Built on **Express.js** and **MongoDB**, it offers dynamic data modeling, automation workflows, advanced user management, and more.
|
|
4
9
|
|
|
@@ -35,7 +40,7 @@ npm i data-primals-engine
|
|
|
35
40
|
```
|
|
36
41
|
or
|
|
37
42
|
```bash
|
|
38
|
-
git clone https://
|
|
43
|
+
git clone https://github.com/anonympins/data-primals-engine.git
|
|
39
44
|
cd data-primals-engine
|
|
40
45
|
npm install
|
|
41
46
|
```
|
|
@@ -78,7 +83,7 @@ By default, the app runs on port **7633**.
|
|
|
78
83
|
|
|
79
84
|
## 🧠 Concepts
|
|
80
85
|
|
|
81
|
-
###
|
|
86
|
+
### Models
|
|
82
87
|
Define schemas using JSON:
|
|
83
88
|
```json
|
|
84
89
|
{
|
|
@@ -115,23 +120,15 @@ Define schemas using JSON:
|
|
|
115
120
|
| model | Stores a model by name | – |
|
|
116
121
|
| modelField | Stores a model field path | – |
|
|
117
122
|
|
|
118
|
-
###
|
|
123
|
+
### Modules
|
|
119
124
|
Activatable features:
|
|
120
125
|
- `mongodb`, `data`, `user`, `workflow`, `file`, `assistant`, `swagger`
|
|
121
126
|
|
|
122
|
-
###
|
|
127
|
+
### Starter Packs
|
|
123
128
|
- **E-commerce**: Products, orders, KPIs
|
|
124
129
|
- **CRM**: Contacts, leads, interactions
|
|
125
130
|
- **Website/blog**: Pages, posts, i18n
|
|
126
131
|
|
|
127
|
-
### 4. Workflows
|
|
128
|
-
Automate business processes:
|
|
129
|
-
- **Triggers**: `DataAdded`, `DataUpdated`, `Scheduled`, `Manual`
|
|
130
|
-
- **Actions**: `CreateData`, `UpdateData`, `SendEmail`, `ApiCall`
|
|
131
|
-
|
|
132
|
-
Example:
|
|
133
|
-
> When a new order is created, email the customer, update stock, and notify logistics.
|
|
134
|
-
|
|
135
132
|
---
|
|
136
133
|
|
|
137
134
|
## 🔌 API Examples (using `curl`)
|
|
@@ -440,7 +437,7 @@ data-primals-engine/
|
|
|
440
437
|
│ ├── engine.js // The Express engine that serves the API
|
|
441
438
|
│ ├── constants.js // The inner-application constants definitions
|
|
442
439
|
│ ├── packs.js // The packs that will be loaded and available with installPack() method
|
|
443
|
-
│ ├── defaultModels.js // The default models available
|
|
440
|
+
│ ├── defaultModels.js // The default models available at startup.
|
|
444
441
|
│ ├── ...
|
|
445
442
|
└── server.js
|
|
446
443
|
```
|
|
@@ -473,8 +470,93 @@ A workflow is composed of two main parts: **Triggers** and **Actions**.
|
|
|
473
470
|
|
|
474
471
|
See the details of the workflow models for more details.
|
|
475
472
|
|
|
473
|
+
## ⚡ Dynamic API Endpoints
|
|
474
|
+
Beyond standard CRUD operations, data-primals-engine allows you to create your own custom API endpoints directly from the UI. This feature acts like a built-in serverless function environment, enabling you to write custom business logic, integrate with third-party services, or create complex data aggregations on the fly.
|
|
475
|
+
|
|
476
|
+
Your code is executed in a secure, isolated sandbox, with access to the core data functions and the incoming request context.
|
|
477
|
+
|
|
478
|
+
### How It Works
|
|
479
|
+
Define an Endpoint: You create a new document in the endpoint model.
|
|
480
|
+
Write Your Logic: In the code field, you write the JavaScript that will be executed.
|
|
481
|
+
Activate: The engine automatically listens for requests on /api/actions/:path that match your endpoint's definition.
|
|
482
|
+
|
|
483
|
+
### The endpoint Model
|
|
484
|
+
To create a custom endpoint, you need to define a document with the following structure:
|
|
485
|
+
```json
|
|
486
|
+
{
|
|
487
|
+
"name": "GetContactPostCount",
|
|
488
|
+
"path": "postCount/:name",
|
|
489
|
+
"method": "GET",
|
|
490
|
+
"code": "const posts = await db.find('content', { author: { $find: { $eq: ['$lastName', request.params.name]}}}); return { postCount: posts.length };",
|
|
491
|
+
"isActive": true
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
| Field | Type | Description |
|
|
495
|
+
|:---------|:--------|:---------------------------------------------------------------------|
|
|
496
|
+
| name | string | A descriptive name for your endpoint (e.g., "Calculate User Stats"). |
|
|
497
|
+
| path | string | The URL path. It can include parameters like :id. |
|
|
498
|
+
| method | enum | The HTTP method: GET, POST, PUT, PATCH, or DELETE. |
|
|
499
|
+
| code | code | The JavaScript code to execute when the endpoint is called. |
|
|
500
|
+
| isActive | boolean | A flag to enable or disable the endpoint without deleting it. |
|
|
476
501
|
---
|
|
477
502
|
|
|
503
|
+
### The Execution Context
|
|
504
|
+
Your JavaScript code runs in an async context and has access to several global objects that are securely injected into the sandbox:
|
|
505
|
+
|
|
506
|
+
#### The context Object
|
|
507
|
+
> This object contains all the information about the incoming HTTP request.
|
|
508
|
+
- context.request.**body**: The parsed request body (for POST, PUT, PATCH).
|
|
509
|
+
- context.request.**query**: The URL query parameters as an object.
|
|
510
|
+
- context.request.**params**: The URL path parameters (e.g., username from /user-summary/:username).
|
|
511
|
+
- context.request.**headers**: The incoming request headers.
|
|
512
|
+
|
|
513
|
+
#### The db Object
|
|
514
|
+
> A secure API to interact with the database. All methods are async and must be awaited. They automatically operate within the authenticated user's permissions.
|
|
515
|
+
- await db.**create**(modelName, dataObject): Inserts a new document.
|
|
516
|
+
- await db.**find**(modelName, filter): Finds multiple documents. Returns an array.
|
|
517
|
+
- await db.**findOne**(modelName, filter): Finds a single document. Returns an object or null.
|
|
518
|
+
- await db.**update**(modelName, filter, updateObject): Partially updates documents matching the filter (similar to a PATCH).
|
|
519
|
+
- await db.**delete**(modelName, filter): Deletes documents matching the filter.
|
|
520
|
+
|
|
521
|
+
#### The logger Object
|
|
522
|
+
> A safe way to log messages from your script. These logs will be collected and can be returned in the API response if an error occurs, which is very useful for debugging.
|
|
523
|
+
- logger.**info**(...args)
|
|
524
|
+
- logger.**warn**(...args)
|
|
525
|
+
- logger.**error**(...args)
|
|
526
|
+
|
|
527
|
+
#### The env Object
|
|
528
|
+
> Provides access to the user-defined variables stored in the env model, not the server's process.env.
|
|
529
|
+
- await env.**get**(variableName): Retrieves a single environment variable's value.
|
|
530
|
+
- await env.**getAll**(): Retrieves all user environment variables as an object.
|
|
531
|
+
|
|
532
|
+
#### Example: Creating a User Summary Endpoint
|
|
533
|
+
Let's create an endpoint that fetches a user's profile and counts how many posts they have published.
|
|
534
|
+
1. Create the Endpoint Document
|
|
535
|
+
Create a new document in the endpoint model with the following data:
|
|
536
|
+
```json
|
|
537
|
+
{
|
|
538
|
+
"name": "Get User Summary",
|
|
539
|
+
"path": "user-summary/:username",
|
|
540
|
+
"method": "GET",
|
|
541
|
+
"isActive": true,
|
|
542
|
+
"code": "const { username } = context.request.params;\n\n if (!username) {\n logger.error('Username parameter is required.');\n return { success: false, error: 'Username is required.' };\n }\n\n logger.info(`Fetching summary for user: ${username}`);\n\n // Fetch the user profile using the sandboxed db API\n const userProfile = await db.findOne('userProfile', { username: username });\n\n if (!userProfile) {\n return { success: false, error: 'User not found' };\n }\n\n // Count the user's published posts\n const posts = await db.find('post', { \n authorId: userProfile._id.toString(), \n status: 'published' \n });\n\n return {\n profile: userProfile,\n publishedPosts: posts.length\n };\n});",
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
2. Call the New Endpoint
|
|
546
|
+
You can now call this custom endpoint like any other API route:
|
|
547
|
+
|
|
548
|
+
Expected response :
|
|
549
|
+
```json
|
|
550
|
+
{
|
|
551
|
+
"profile": {
|
|
552
|
+
"_id": "60d0fe4f5311236168a109ca",
|
|
553
|
+
"username": "demo",
|
|
554
|
+
"bio": "A demo user profile.",
|
|
555
|
+
"...": "..."
|
|
556
|
+
},
|
|
557
|
+
"publishedPosts": 15
|
|
558
|
+
}
|
|
559
|
+
```
|
|
478
560
|
## 🤝 Contributing
|
|
479
561
|
|
|
480
562
|
1. Fork the repo
|
|
@@ -492,4 +574,4 @@ Distributed under the **MIT License**. See `LICENSE` file.
|
|
|
492
574
|
|
|
493
575
|
---
|
|
494
576
|
|
|
495
|
-
## 🔼 Back to Top
|
|
577
|
+
## [🔼](#) Back to Top
|
package/client/src/App.jsx
CHANGED
|
@@ -55,12 +55,13 @@ import {useCookies, CookiesProvider} from "react-cookie";
|
|
|
55
55
|
import { translations as allTranslations} from "../../src/i18n.js";
|
|
56
56
|
import {getBrowserRandom, getRandom} from "../../src/core.js";
|
|
57
57
|
import {getUserHash} from "../../src/data.js";
|
|
58
|
-
import {seoTitle} from "./constants.js";
|
|
58
|
+
import {langs, seoTitle} from "./constants.js";
|
|
59
59
|
import {host, useAI} from "../../src/constants.js";
|
|
60
60
|
import i18next from "i18next";
|
|
61
61
|
import {websiteTranslations} from "./translations.js";
|
|
62
62
|
|
|
63
63
|
import { Tooltip } from 'react-tooltip';
|
|
64
|
+
import {availableLangs} from "data-primals-engine/constants";
|
|
64
65
|
|
|
65
66
|
let queryClient = new QueryClient();
|
|
66
67
|
|
|
@@ -84,7 +85,7 @@ function TopBar({header}) {
|
|
|
84
85
|
return <>{header}</>;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function Layout ({header,
|
|
88
|
+
function Layout ({header, routes, body, footer}) {
|
|
88
89
|
const [cookies, setCookie, removeCookie] = useCookies(['username']);
|
|
89
90
|
const { i18n, t } = useTranslation();
|
|
90
91
|
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
@@ -157,8 +158,6 @@ function Layout ({header, translationMutation, routes, body, footer}) {
|
|
|
157
158
|
);
|
|
158
159
|
|
|
159
160
|
|
|
160
|
-
const [lastLang, setLastLang] = useState(null);
|
|
161
|
-
|
|
162
161
|
// AJOUT : Liste des suggestions de prompts
|
|
163
162
|
const suggestedPrompts = [
|
|
164
163
|
{
|
|
@@ -180,37 +179,11 @@ function Layout ({header, translationMutation, routes, body, footer}) {
|
|
|
180
179
|
];
|
|
181
180
|
|
|
182
181
|
const { setCurrentTourSteps, setAllTourSteps, currentTour, setCurrentTour } = useUI();
|
|
183
|
-
const [translations, setTranslations] = useState([]);
|
|
184
182
|
|
|
185
183
|
|
|
186
184
|
const isProd = import.meta.env.MODE === 'production';
|
|
187
185
|
const loc = useLocation();
|
|
188
186
|
|
|
189
|
-
const changeLanguage = (newLang) => {
|
|
190
|
-
if (typeof(newLang) === 'string' && newLang) {
|
|
191
|
-
|
|
192
|
-
i18n.changeLanguage(newLang, (err)=>{
|
|
193
|
-
|
|
194
|
-
i18next.removeResourceBundle(lang, "translation");
|
|
195
|
-
i18next.addResourceBundle(newLang, 'translation', {...websiteTranslations[newLang]['translation'], ...translations[newLang]?.['translation']});
|
|
196
|
-
|
|
197
|
-
gtag("event", "change_language "+newLang);
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
useEffect(() => {
|
|
203
|
-
if( me ){
|
|
204
|
-
translationMutation?.mutateAsync(lang).then(d => {
|
|
205
|
-
setTranslations(d);
|
|
206
|
-
setLastLang(lang);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
}, [me, lang])
|
|
210
|
-
|
|
211
|
-
useEffect(() => {
|
|
212
|
-
changeLanguage(lang);
|
|
213
|
-
}, [lang]);
|
|
214
187
|
|
|
215
188
|
const [currentProfile, setCurrentProfile] = useLocalStorage('profile', null);
|
|
216
189
|
|
|
@@ -225,24 +198,6 @@ function Layout ({header, translationMutation, routes, body, footer}) {
|
|
|
225
198
|
}
|
|
226
199
|
}
|
|
227
200
|
|
|
228
|
-
useEffect(() => {
|
|
229
|
-
if (Array.isArray(translations)) {
|
|
230
|
-
var trs= {};
|
|
231
|
-
translations.forEach(tr =>{
|
|
232
|
-
trs[tr.key] = tr.value;
|
|
233
|
-
});
|
|
234
|
-
trs= { ...allTranslations[lang]['translation'], ...trs};
|
|
235
|
-
|
|
236
|
-
if( lastLang && lang !== lastLang) {
|
|
237
|
-
i18n.removeResourceBundle(lastLang, 'translation');
|
|
238
|
-
}
|
|
239
|
-
i18n.addResourceBundle(lang, 'translation', trs);
|
|
240
|
-
}
|
|
241
|
-
}, [translations]);
|
|
242
|
-
|
|
243
|
-
useEffect(() => {
|
|
244
|
-
changeLanguage(lang);
|
|
245
|
-
}, []);
|
|
246
201
|
|
|
247
202
|
const [refreshReducer, refreshUI]= useReducer((n) => n+1, 0,() => 0);
|
|
248
203
|
|
|
@@ -389,7 +344,9 @@ function Layout ({header, translationMutation, routes, body, footer}) {
|
|
|
389
344
|
|
|
390
345
|
<div className="flex flex-row flex-no-wrap">
|
|
391
346
|
<div className="flex flex-1 flex-no-gap home-header">
|
|
392
|
-
<
|
|
347
|
+
<div className="flex flex-self-end">
|
|
348
|
+
<a href={"https://github.com/anonympins/data-primals-engine"} target={"_blank"} className="link-top"><img src={"/github.svg"} alt={"Github"} /></a>
|
|
349
|
+
</div>
|
|
393
350
|
<div className="flex prior">
|
|
394
351
|
<img
|
|
395
352
|
src="https://web.primals.net/PRIOR.png"
|
|
@@ -575,10 +532,59 @@ function UserPage({notifs,triggerSignin, onAuthenticated}) {
|
|
|
575
532
|
données</p>}</>;
|
|
576
533
|
}
|
|
577
534
|
|
|
535
|
+
const allLangs = Object.keys(langs).map(l => {
|
|
536
|
+
return {value: l, label: langs[l]};
|
|
537
|
+
})
|
|
538
|
+
|
|
578
539
|
const BaseLayout=()=>{
|
|
540
|
+
const [lastLang, setLastLang] = useState(null);
|
|
541
|
+
const { i18n, t} = useTranslation();
|
|
542
|
+
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
543
|
+
const { me, setMe } = useAuthContext();
|
|
544
|
+
const [translations, setTranslations] = useState([]);
|
|
545
|
+
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (Array.isArray(translations)) {
|
|
548
|
+
var trs= {};
|
|
549
|
+
translations.forEach(tr =>{
|
|
550
|
+
trs[tr.key] = tr.value;
|
|
551
|
+
});
|
|
552
|
+
trs= { ...allTranslations[lang]['translation'], ...trs};
|
|
553
|
+
|
|
554
|
+
if( lastLang && lang !== lastLang) {
|
|
555
|
+
i18n.removeResourceBundle(lastLang, 'translation');
|
|
556
|
+
}
|
|
557
|
+
i18n.addResourceBundle(lang, 'translation', trs);
|
|
558
|
+
}
|
|
559
|
+
}, [translations]);
|
|
560
|
+
|
|
561
|
+
const changeLanguage = (newLang) => {
|
|
562
|
+
if (typeof(newLang) === 'string' && newLang) {
|
|
563
|
+
|
|
564
|
+
i18n.changeLanguage(newLang, (err)=>{
|
|
565
|
+
|
|
566
|
+
i18next.removeResourceBundle(lang, "dataEngineTranslations");
|
|
567
|
+
const trs = {...websiteTranslations[newLang]?.['translation']} || {};
|
|
568
|
+
i18next.addResourceBundle(newLang, 'dataEngineTranslations', trs);
|
|
569
|
+
|
|
570
|
+
gtag("event", "change_language "+newLang);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
changeLanguage(lang);
|
|
577
|
+
}, [lang]);
|
|
578
|
+
|
|
579
579
|
return <Layout header={<header className={"flex"}>
|
|
580
580
|
<Tooltip id={"header"}/>
|
|
581
581
|
<h1 className="flex-1">{seoTitle}</h1>
|
|
582
|
+
<div className="center">
|
|
583
|
+
<SelectField label={<FaLanguage />} items={allLangs} onChange={(e) => {
|
|
584
|
+
console.log(e.value);
|
|
585
|
+
changeLanguage(e.value);
|
|
586
|
+
}} />
|
|
587
|
+
</div>
|
|
582
588
|
<div className="flex">
|
|
583
589
|
<FaQuestion data-tooltip-id="header" data-tooltip-content="Documentation" onClick={()=> {
|
|
584
590
|
window.open("https://data.primals.net/en/documentation/", "_blank");
|
package/client/src/App.scss
CHANGED
|
@@ -45,6 +45,9 @@ header {
|
|
|
45
45
|
&.flex-start {
|
|
46
46
|
align-items: start;
|
|
47
47
|
}
|
|
48
|
+
&.flex-self-end {
|
|
49
|
+
align-self: end;
|
|
50
|
+
}
|
|
48
51
|
.flex-left {
|
|
49
52
|
justify-content: flex-start;
|
|
50
53
|
}
|
|
@@ -513,7 +516,6 @@ label + .field, label + input, label + select {
|
|
|
513
516
|
|
|
514
517
|
/* website*/
|
|
515
518
|
#content {
|
|
516
|
-
margin-top: 40px;
|
|
517
519
|
padding: 0;
|
|
518
520
|
h1 {
|
|
519
521
|
margin: 24px 8px;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import { useQuery, useQueryClient } from 'react-query';
|
|
3
3
|
import { useModelContext } from './contexts/ModelContext.jsx';
|
|
4
4
|
import { TextField } from './Field.jsx';
|
|
5
5
|
import { useAuthContext } from './contexts/AuthContext.jsx';
|
|
6
|
-
import {
|
|
6
|
+
import { getDataAsString, getUserId } from 'data-primals-engine/data';
|
|
7
7
|
import { FaEdit, FaTrash } from 'react-icons/fa';
|
|
8
8
|
import Button from './Button.jsx';
|
|
9
|
-
import {mainFieldsTypes} from "../../src/constants.js";
|
|
9
|
+
import { mainFieldsTypes } from "../../src/constants.js";
|
|
10
10
|
import Draggable from "./Draggable.jsx";
|
|
11
|
-
import {useTranslation} from "react-i18next";
|
|
11
|
+
import { useTranslation } from "react-i18next";
|
|
12
12
|
|
|
13
|
-
const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) => {
|
|
13
|
+
const RelationField = ({ field, help, onFocus, onBlur, onChange, value = null }) => {
|
|
14
14
|
const { models, dataByModel, setOnSuccessCallbacks, relationIds, setRelationIds } = useModelContext();
|
|
15
15
|
const { name, relation: modelName } = field;
|
|
16
16
|
const queryClient = useQueryClient();
|
|
@@ -19,45 +19,67 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
19
19
|
const [searchValue, setSearchValue] = useState('');
|
|
20
20
|
const [history, setHistory] = useState([]);
|
|
21
21
|
const { me } = useAuthContext();
|
|
22
|
-
const tr = useTranslation()
|
|
23
|
-
const {t, i18n} = tr
|
|
22
|
+
const tr = useTranslation();
|
|
23
|
+
const { t, i18n } = tr;
|
|
24
24
|
const model = models?.find(f => f.name === modelName && f._user === me?.username);
|
|
25
25
|
|
|
26
26
|
// Fetch related data based on search value
|
|
27
27
|
const { data: results = [], isError, refetch } = useQuery(
|
|
28
|
-
|
|
28
|
+
// La clé de la requête inclut maintenant le filtre de relation pour une mise en cache correcte
|
|
29
|
+
['api/search', model?.name, field?.name, searchValue, JSON.stringify(field.relationFilter)],
|
|
29
30
|
async ({ signal }) => {
|
|
30
31
|
if (!model) return [];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
|
|
33
|
+
// --- DÉBUT DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
|
|
34
|
+
|
|
35
|
+
// 1. On récupère le filtre permanent défini dans le modèle.
|
|
36
|
+
// S'il n'y en a pas, on utilise un objet vide qui n'aura aucun effet.
|
|
37
|
+
const permanentFilter = field.relationFilter || {};
|
|
38
|
+
|
|
39
|
+
// 2. On construit le filtre basé sur la recherche de l'utilisateur.
|
|
40
|
+
const searchFilter = {};
|
|
41
|
+
if (searchValue) {
|
|
42
|
+
const orConditions = [];
|
|
43
|
+
// Recherche sur les champs principaux (asMain)
|
|
36
44
|
model.fields.forEach(f => {
|
|
37
|
-
if (f.asMain) {
|
|
38
|
-
|
|
39
|
-
orFilter.push({"$regexMatch": { input: '$'+f.name, regex: searchValue}});
|
|
45
|
+
if (f.asMain && mainFieldsTypes.includes(f.type)) {
|
|
46
|
+
orConditions.push({ [f.name]: { "$regex": searchValue, "$options": "i" } });
|
|
40
47
|
}
|
|
41
|
-
})
|
|
42
|
-
|
|
48
|
+
});
|
|
49
|
+
// Si aucun champ principal, recherche sur les champs texte
|
|
50
|
+
if (orConditions.length === 0) {
|
|
43
51
|
model.fields.forEach(f => {
|
|
44
|
-
if (["string", "string_t", "richtext", "url"]
|
|
45
|
-
|
|
52
|
+
if (["string", "string_t", "richtext", "url"].includes(f.type)) {
|
|
53
|
+
orConditions.push({ [f.name]: { "$regex": searchValue, "$options": "i" } });
|
|
46
54
|
}
|
|
47
55
|
});
|
|
48
|
-
if (!orFilter.length) {
|
|
49
|
-
orFilter.push({"$eq": ['_id', searchValue]});
|
|
50
|
-
}
|
|
51
56
|
}
|
|
52
|
-
|
|
57
|
+
// Si toujours rien, on cherche sur l'ID (utile pour les développeurs)
|
|
58
|
+
if (orConditions.length === 0) {
|
|
59
|
+
orConditions.push({ "_id": searchValue });
|
|
60
|
+
}
|
|
61
|
+
searchFilter['$or'] = orConditions;
|
|
53
62
|
}
|
|
63
|
+
|
|
64
|
+
// 3. On combine les deux filtres avec un opérateur $and.
|
|
65
|
+
// Un document devra correspondre au filtre permanent ET au filtre de recherche.
|
|
66
|
+
const finalFilter = {
|
|
67
|
+
"$and": [
|
|
68
|
+
permanentFilter,
|
|
69
|
+
searchFilter
|
|
70
|
+
]
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// --- FIN DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
|
|
74
|
+
|
|
54
75
|
const params = new URLSearchParams();
|
|
55
|
-
params.append('_user', getUserId(me));
|
|
56
76
|
params.append('model', field.relation);
|
|
57
|
-
params.append('limit', '
|
|
58
|
-
params.append('depth', '2');
|
|
77
|
+
params.append('limit', '100'); // Limite raisonnable pour les suggestions
|
|
78
|
+
params.append('depth', '2');
|
|
79
|
+
|
|
59
80
|
return fetch(`/api/data/search?${params.toString()}`, {
|
|
60
|
-
|
|
81
|
+
// On envoie le filtre final et complet
|
|
82
|
+
body: JSON.stringify({ filter: finalFilter }),
|
|
61
83
|
method: 'POST',
|
|
62
84
|
signal: signal,
|
|
63
85
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -65,9 +87,11 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
65
87
|
.then(e => e.json())
|
|
66
88
|
.then(e => e.data);
|
|
67
89
|
},
|
|
68
|
-
{ enabled: !!model }
|
|
90
|
+
{ enabled: !!model && showResults } // La requête ne s'exécute que si le panneau de résultats est visible
|
|
69
91
|
);
|
|
70
92
|
|
|
93
|
+
// ... (le reste du composant reste identique) ...
|
|
94
|
+
|
|
71
95
|
useEffect(() => {
|
|
72
96
|
setSearchValue('');
|
|
73
97
|
setHistory([]);
|
|
@@ -100,7 +124,6 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
100
124
|
}
|
|
101
125
|
}, [value]);
|
|
102
126
|
|
|
103
|
-
//console.log(field, value);
|
|
104
127
|
const updateValue = () => {
|
|
105
128
|
if (!field.multiple) {
|
|
106
129
|
const v = history.find(d => d._id === value);
|
|
@@ -108,7 +131,7 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
108
131
|
setSearchValue(getDataAsString(model, v, tr, models) || '');
|
|
109
132
|
onChange({ name, value });
|
|
110
133
|
} else {
|
|
111
|
-
//
|
|
134
|
+
// Ne rien faire si la valeur n'est pas dans l'historique pour éviter d'effacer
|
|
112
135
|
}
|
|
113
136
|
} else {
|
|
114
137
|
if (Array.isArray(value)) {
|
|
@@ -133,7 +156,7 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
133
156
|
return value;
|
|
134
157
|
});
|
|
135
158
|
} else {
|
|
136
|
-
onChange({ name, value });
|
|
159
|
+
onChange({ name, value: selectedValues });
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
setResultsVisible(false);
|
|
@@ -141,10 +164,6 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
141
164
|
e.preventDefault();
|
|
142
165
|
};
|
|
143
166
|
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
if (showResults) refetch();
|
|
146
|
-
}, [showResults]);
|
|
147
|
-
|
|
148
167
|
const handleRemove = (element) => {
|
|
149
168
|
if (!field.multiple) return;
|
|
150
169
|
setSelectedValues(values => {
|
|
@@ -162,6 +181,7 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
162
181
|
inputRef.current.ref.setAttribute('autocomplete', 'off');
|
|
163
182
|
}
|
|
164
183
|
}, [inputRef]);
|
|
184
|
+
|
|
165
185
|
return (
|
|
166
186
|
<div onFocus={onFocus} onBlur={onBlur} className="field field-relation flex flex-row flex-start flex-1">
|
|
167
187
|
{help && <div className={"flex help"}>{help}</div>}
|
|
@@ -176,17 +196,15 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
176
196
|
id={field.name}
|
|
177
197
|
onFocus={e => {
|
|
178
198
|
setResultsVisible(true);
|
|
179
|
-
queryClient.invalidateQueries(['api/search', field.name, searchValue]);
|
|
180
199
|
}}
|
|
181
200
|
value={searchValue}
|
|
182
201
|
onChange={e => {
|
|
183
|
-
if (!e.target.value) onChange({ name, value:
|
|
184
|
-
else onChange({ name, value: '' });
|
|
202
|
+
if (!e.target.value) onChange({ name, value: null });
|
|
185
203
|
setResultsVisible(true);
|
|
186
204
|
setSearchValue(e.target.value);
|
|
187
205
|
}}
|
|
188
206
|
onBlur={(e) => {
|
|
189
|
-
setResultsVisible(false);
|
|
207
|
+
setTimeout(() => setResultsVisible(false), 150); // Léger délai pour permettre le clic sur un résultat
|
|
190
208
|
}}
|
|
191
209
|
/>
|
|
192
210
|
<Button
|
|
@@ -200,11 +218,6 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
200
218
|
<div className="results" onKeyDown={e => {
|
|
201
219
|
if( e.key === 'Escape' )
|
|
202
220
|
setResultsVisible(false);
|
|
203
|
-
}} onBlur={e => {
|
|
204
|
-
const t = e.target.parentNode.children[e.target.parentNode.childElementCount-1];
|
|
205
|
-
if(t === e.target){
|
|
206
|
-
setResultsVisible(false);
|
|
207
|
-
}
|
|
208
221
|
}} >
|
|
209
222
|
{!isError &&
|
|
210
223
|
(results || []).map(r => {
|
|
@@ -224,7 +237,7 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
224
237
|
{field.multiple && selectedValues.length > 0 && (
|
|
225
238
|
<div ref={ref} tabIndex={0} className="selected-values flex flex-border flex-row flex-no-gap flex-start flex-1">
|
|
226
239
|
<Draggable items={selectedValues} renderItem={(id,i) =>{
|
|
227
|
-
const val = history.find(f => f._id === id) || dataByModel[modelName]
|
|
240
|
+
const val = history.find(f => f._id === id) || dataByModel[modelName]?.find(f => f._id === id);
|
|
228
241
|
if (!val) {
|
|
229
242
|
return <div className="flex" key={id}>data non chargée<FaTrash onClick={() => handleRemove(id)} /></div>;
|
|
230
243
|
}
|
|
@@ -239,7 +252,6 @@ const RelationField = ({ field, help, onFocus, onBlur, onChange, value=null }) =
|
|
|
239
252
|
}} />
|
|
240
253
|
</div>
|
|
241
254
|
)}
|
|
242
|
-
|
|
243
255
|
</div>
|
|
244
256
|
);
|
|
245
257
|
};
|
package/client/src/constants.js
CHANGED
|
@@ -66,4 +66,23 @@ export const OPERAND_TYPES = {
|
|
|
66
66
|
|
|
67
67
|
export const getHost = () => {
|
|
68
68
|
return process.env.HOST || host || 'localhost';
|
|
69
|
-
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
export const langs = {
|
|
74
|
+
fr: "Français",
|
|
75
|
+
en: "English",
|
|
76
|
+
ar: "عربي",
|
|
77
|
+
fa: "فارسی",
|
|
78
|
+
it: "Italiano",
|
|
79
|
+
es: "Español",
|
|
80
|
+
el: "Ελληνικά",
|
|
81
|
+
de: "Deutsch",
|
|
82
|
+
cs: "Čeština",
|
|
83
|
+
sv: "Svenska",
|
|
84
|
+
pt: "Português",
|
|
85
|
+
ja: "日本語",
|
|
86
|
+
zh: "简体中文",
|
|
87
|
+
ru: "Русский"
|
|
88
|
+
};
|
package/client/src/filter.js
CHANGED
|
@@ -258,4 +258,22 @@ export const pagedFilterToMongoConds = (pagedFilters, model) => {
|
|
|
258
258
|
});
|
|
259
259
|
return c;
|
|
260
260
|
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Remplace les placeholders dans un objet filtre.
|
|
265
|
+
* Gère {{userId}}.
|
|
266
|
+
*/
|
|
267
|
+
export const processFilterPlaceholders = (filter, user) => {
|
|
268
|
+
const processedFilter = JSON.parse(JSON.stringify(filter));
|
|
269
|
+
for (const key in processedFilter) {
|
|
270
|
+
if (processedFilter[key] === '{{userId}}') {
|
|
271
|
+
// Dans le système Primals, le champ utilisateur est souvent `_user`
|
|
272
|
+
// et contient le nom d'utilisateur, pas l'ID. Adaptez si besoin.
|
|
273
|
+
processedFilter[key] = user.username;
|
|
274
|
+
} else if (typeof processedFilter[key] === 'object' && processedFilter[key] !== null) {
|
|
275
|
+
processedFilter[key] = processFilterPlaceholders(processedFilter[key], user);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return processedFilter;
|
|
261
279
|
};
|