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 CHANGED
@@ -1,4 +1,9 @@
1
1
  # Data Primals Engine
2
+ [![Node.js CI](https://github.com/anonympins/data-primals-engine/actions/workflows/node.js.yml/badge.svg?branch=main)](https://github.com/anonympins/data-primals-engine/actions/workflows/node.js.yml)
3
+ ![](https://img.shields.io/npm/dw/data-primals-engine)
4
+ ![](https://img.shields.io/npm/last-update/data-primals-engine)
5
+ ![](https://img.shields.io/github/v/release/anonympins/data-primals-engine)
6
+ ![](https://img.shields.io/github/license/anonympins/data-primals-engine)
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://your-repo/data-primals-engine.git
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
- ### 1. Models
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
- ### 2. Modules
123
+ ### Modules
119
124
  Activatable features:
120
125
  - `mongodb`, `data`, `user`, `workflow`, `file`, `assistant`, `swagger`
121
126
 
122
- ### 3. Starter Packs
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 to import
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
@@ -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, translationMutation, routes, body, footer}) {
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
- <a href={"https://github.com/anonympins/data-primals-engine"} target={"_blank"} className="link-top"><img src={"/github.svg"} alt={"Github"} /></a>
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");
@@ -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, useCallback } from 'react';
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 {conditionToApiSearchFilter, getDataAsString, getUserId} from 'data-primals-engine/data';
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
- ['api/search', model?.name, field?.name, searchValue],
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
- const orFilter = [];
32
- let filter = {};
33
- if( field.relationFilter ){
34
- filter = {"$and": field.relationFilter.filter};
35
- }else if ( searchValue) {
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
- if( f.type !== 'relation' && mainFieldsTypes.includes(f.type))
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
- if (!orFilter.length) {
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"]?.includes(f.type)) {
45
- orFilter.push({"$regexMatch": { input: '$'+f.name, regex: searchValue}});
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
- filter = {'$or':orFilter};
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', '1000');
58
- params.append('depth', '2'); // Fetch related data with 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
- body: JSON.stringify({ filter }),
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
- //onChange({ name, value: null });
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].find(f => f._id === id);
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
  };
@@ -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
+ };
@@ -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
  };