Skip to content

Commit 57c7b01

Browse files
committed
Merge branch 'next' of github.com:devforth/adminforth into next
2 parents 16e0b97 + 4c2327e commit 57c7b01

24 files changed

+953
-2030
lines changed

adminforth/dataConnectors/baseConnector.ts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { suggestIfTypo } from "../modules/utils.js";
1010
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from "../types/Common.js";
1111
import { randomUUID } from "crypto";
12+
import dayjs from "dayjs";
1213

1314

1415
export default class AdminForthBaseConnector implements IAdminForthDataSourceConnectorBase {
@@ -164,9 +165,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
164165
return { ok: false, error: `Value for operator '${filters.operator}' should not be empty array, in filter object: ${JSON.stringify(filters) }` };
165166
}
166167
}
167-
filters.value = filters.value.map((val: any) => this.setFieldValue(fieldObj, val));
168+
filters.value = filters.value.map((val: any) => this.validateAndSetFieldValue(fieldObj, val));
168169
} else {
169-
filtersAsSingle.value = this.setFieldValue(fieldObj, filtersAsSingle.value);
170+
filtersAsSingle.value = this.validateAndSetFieldValue(fieldObj, filtersAsSingle.value);
170171
}
171172
} else if (filtersAsSingle.insecureRawSQL || filtersAsSingle.insecureRawNoSQL) {
172173
// if "insecureRawSQL" filter is insecure sql string
@@ -219,6 +220,90 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
219220
throw new Error('Method not implemented.');
220221
}
221222

223+
validateAndSetFieldValue(field: AdminForthResourceColumn, value: any): any {
224+
// Int
225+
if (field.type === AdminForthDataTypes.INTEGER) {
226+
if (value === "" || value === null) {
227+
return this.setFieldValue(field, null);
228+
}
229+
if (!Number.isFinite(value)) {
230+
throw new Error(`Value is not an integer. Field ${field.name} with type is ${field.type}, but got value: ${value} with type ${typeof value}`);
231+
}
232+
return this.setFieldValue(field, value);
233+
}
234+
235+
// Float
236+
if (field.type === AdminForthDataTypes.FLOAT) {
237+
if (value === "" || value === null) {
238+
return this.setFieldValue(field, null);
239+
}
240+
241+
if (typeof value !== "number" || !Number.isFinite(value)) {
242+
throw new Error(
243+
`Value is not a float. Field ${field.name} with type is ${field.type}, but got value: ${String(value)} with type ${typeof value}`
244+
);
245+
}
246+
247+
return this.setFieldValue(field, value);
248+
}
249+
250+
// Decimal
251+
if (field.type === AdminForthDataTypes.DECIMAL) {
252+
if (value === "" || value === null) {
253+
return this.setFieldValue(field, null);
254+
}
255+
if (typeof value === "string") {
256+
const string = value.trim();
257+
if (!string) {
258+
return this.setFieldValue(field, null);
259+
}
260+
if (Number.isFinite(Number(string))) {
261+
return this.setFieldValue(field, string);
262+
}
263+
throw new Error(`Value is not a decimal. Field ${field.name} with type is ${field.type}, but got value: ${value} with type ${typeof value}`);
264+
}
265+
266+
throw new Error(`Value is not a decimal. Field ${field.name} with type is ${field.type}, but got value: ${String(value)} with type ${typeof value}`);
267+
}
268+
269+
// Date
270+
271+
272+
// DateTime
273+
if (field.type === AdminForthDataTypes.DATETIME) {
274+
if (value === "" || value === null) {
275+
return this.setFieldValue(field, null);
276+
}
277+
if (!dayjs(value).isValid()) {
278+
throw new Error(`Value is not a valid datetime. Field ${field.name} with type is ${field.type}, but got value: ${value} with type ${typeof value}`);
279+
}
280+
return this.setFieldValue(field, value);
281+
}
282+
283+
// Time
284+
285+
// Boolean
286+
if (field.type === AdminForthDataTypes.BOOLEAN) {
287+
if (value === "" || value === null) {
288+
return this.setFieldValue(field, null);
289+
}
290+
if (typeof value !== 'boolean') {
291+
throw new Error(`Value is not a boolean. Field ${field.name} with type is ${field.type}, but got value: ${value} with type ${typeof value}`);
292+
}
293+
return this.setFieldValue(field, value);
294+
}
295+
296+
// JSON
297+
298+
// String
299+
if (field.type === AdminForthDataTypes.STRING) {
300+
if (value === "" || value === null){
301+
return this.setFieldValue(field, null);
302+
}
303+
}
304+
return this.setFieldValue(field, value);
305+
}
306+
222307
getMinMaxForColumnsWithOriginalTypes({ resource, columns }: { resource: AdminForthResource; columns: AdminForthResourceColumn[]; }): Promise<{ [key: string]: { min: any; max: any; }; }> {
223308
throw new Error('Method not implemented.');
224309
}
@@ -268,7 +353,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
268353
}
269354
if (filledRecord[col.name] !== undefined) {
270355
// no sense to set value if it is not defined
271-
recordWithOriginalValues[col.name] = this.setFieldValue(col, filledRecord[col.name]);
356+
recordWithOriginalValues[col.name] = this.validateAndSetFieldValue(col, filledRecord[col.name]);
272357
}
273358
}
274359

@@ -325,7 +410,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
325410
Update record received field '${field}' (with value ${newValues[field]}), but such column not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}
326411
`);
327412
}
328-
recordWithOriginalValues[col.name] = this.setFieldValue(col, newValues[col.name]);
413+
recordWithOriginalValues[col.name] = this.validateAndSetFieldValue(col, newValues[col.name]);
329414
}
330415
const record = await this.getRecordByPrimaryKey(resource, recordId);
331416
let error: string | null = null;

adminforth/dataConnectors/mongo.ts

Lines changed: 84 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import dayjs from 'dayjs';
22
import { MongoClient } from 'mongodb';
3-
import { Decimal128 } from 'bson';
3+
import { Decimal128, Double } from 'bson';
44
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource } from '../types/Back.js';
55
import AdminForthBaseConnector from './baseConnector.js';
66

@@ -9,6 +9,24 @@ import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirection
99
const escapeRegex = (value) => {
1010
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Escapes special characters
1111
};
12+
function normalizeMongoValue(v: any) {
13+
if (v == null) {
14+
return v;
15+
}
16+
if (v instanceof Decimal128) {
17+
return v.toString();
18+
}
19+
if (v instanceof Double) {
20+
return v.valueOf();
21+
}
22+
if (typeof v === "object" && v.$numberDecimal) {
23+
return String(v.$numberDecimal);
24+
}
25+
if (typeof v === "object" && v.$numberDouble) {
26+
return Number(v.$numberDouble);
27+
}
28+
return v;
29+
}
1230

1331
class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector {
1432

@@ -122,30 +140,30 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
122140
}
123141

124142
return Array.from(fieldTypes.entries()).map(([name, types]) => {
125-
const primaryKey = name === '_id';
126-
127-
const priority = ['datetime', 'date', 'integer', 'float', 'boolean', 'json', 'decimal', 'string'];
128-
129-
const matched = priority.find(t => types.has(t)) || 'string';
130-
131-
const typeMap: Record<string, string> = {
132-
string: 'STRING',
133-
integer: 'INTEGER',
134-
float: 'FLOAT',
135-
boolean: 'BOOLEAN',
136-
datetime: 'DATETIME',
137-
date: 'DATE',
138-
json: 'JSON',
139-
decimal: 'DECIMAL',
140-
};
141-
return {
142-
name,
143-
type: typeMap[matched] ?? 'STRING',
144-
...(primaryKey ? { isPrimaryKey: true } : {}),
145-
sampleValue: sampleValues.get(name),
146-
};
143+
const primaryKey = name === '_id';
144+
145+
const priority = ['datetime', 'date', 'decimal', 'integer', 'float', 'boolean', 'json', 'string'];
146+
147+
const matched = priority.find(t => types.has(t)) || 'string';
148+
149+
const typeMap: Record<string, AdminForthDataTypes> = {
150+
string: AdminForthDataTypes.STRING,
151+
integer: AdminForthDataTypes.INTEGER,
152+
float: AdminForthDataTypes.FLOAT,
153+
boolean: AdminForthDataTypes.BOOLEAN,
154+
datetime: AdminForthDataTypes.DATETIME,
155+
date: AdminForthDataTypes.DATE,
156+
json: AdminForthDataTypes.JSON,
157+
decimal: AdminForthDataTypes.DECIMAL,
158+
};
159+
return {
160+
name,
161+
type: typeMap[matched] ?? AdminForthDataTypes.STRING,
162+
...(primaryKey ? { isPrimaryKey: true } : {}),
163+
sampleValue: sampleValues.get(name),
164+
};
147165
});
148-
}
166+
}
149167

150168

151169
async discoverFields(resource) {
@@ -200,20 +218,37 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
200218

201219

202220
setFieldValue(field, value) {
203-
if (field.type == AdminForthDataTypes.DATETIME) {
204-
if (!value) {
205-
return null;
206-
}
207-
return dayjs(value).toDate();
208-
209-
} else if (field.type == AdminForthDataTypes.BOOLEAN) {
210-
return value === null ? null : (value ? true : false);
211-
} else if (field.type == AdminForthDataTypes.DECIMAL) {
212-
if (value === null || value === undefined) {
221+
if (value === undefined) return undefined;
222+
if (value === null) return null;
223+
224+
if (field.type === AdminForthDataTypes.DATETIME) {
225+
if (value === "" || value === null) {
213226
return null;
214227
}
215-
return Decimal128.fromString(value?.toString());
228+
return dayjs(value).isValid() ? dayjs(value).toDate() : null;
216229
}
230+
231+
if (field.type === AdminForthDataTypes.INTEGER) {
232+
if (value === "" || value === null) {
233+
return null;
234+
}
235+
return Number.isFinite(value) ? Math.trunc(value) : null;
236+
}
237+
238+
if (field.type === AdminForthDataTypes.FLOAT) {
239+
if (value === "" || value === null) {
240+
return null;
241+
}
242+
return Number.isFinite(value) ? value : null;
243+
}
244+
245+
if (field.type === AdminForthDataTypes.DECIMAL) {
246+
if (value === "" || value === null) {
247+
return null;
248+
}
249+
return value.toString();
250+
}
251+
217252
return value;
218253
}
219254

@@ -251,7 +286,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
251286
return { $expr: { [mongoExprOp]: [left, right] } };
252287
}
253288
const column = resource.dataSourceColumns.find((col) => col.name === (filter as IAdminForthSingleFilter).field);
254-
if (['integer', 'decimal', 'float'].includes(column.type)) {
289+
if ([AdminForthDataTypes.INTEGER, AdminForthDataTypes.DECIMAL, AdminForthDataTypes.FLOAT].includes(column.type)) {
255290
return { [(filter as IAdminForthSingleFilter).field]: this.OperatorsMap[filter.operator](+(filter as IAdminForthSingleFilter).value) };
256291
}
257292
return { [(filter as IAdminForthSingleFilter).field]: this.OperatorsMap[filter.operator]((filter as IAdminForthSingleFilter).value) };
@@ -313,13 +348,20 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
313348
async getMinMaxForColumnsWithOriginalTypes({ resource, columns }) {
314349
const tableName = resource.table;
315350
const collection = this.client.db().collection(tableName);
316-
const result = {};
351+
const result: Record<string, { min: any; max: any }> = {};
352+
317353
for (const column of columns) {
318-
result[column] = await collection
319-
.aggregate([
320-
{ $group: { _id: null, min: { $min: `$${column}` }, max: { $max: `$${column}` } } },
321-
])
322-
.toArray();
354+
const [doc] = await collection
355+
.aggregate([
356+
{ $group: { _id: null, min: { $min: `$${column.name}` }, max: { $max: `$${column.name}` } } },
357+
{ $project: { _id: 0, min: 1, max: 1 } },
358+
])
359+
.toArray();
360+
361+
result[column.name] = {
362+
min: normalizeMongoValue(doc?.min),
363+
max: normalizeMongoValue(doc?.max),
364+
};
323365
}
324366
return result;
325367
}

adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,26 @@ Notes and tips:
469469
- Requirements:
470470
- Required `<tr></tr>` structure around `<slot />`
471471
472+
## List table three dots menu injection
473+
474+
`customActionIconsThreeDotsMenuItems` allows to inject component inside three dots menu for each recod in list table.
475+
476+
```ts
477+
options: {
478+
pageInjections: {
479+
list: {
480+
customActionIconsThreeDotsMenuItems: {
481+
file: '@@/ApartRowRenderer.vue',
482+
meta: {
483+
// You can pass any meta your component may read
484+
}
485+
}
486+
}
487+
}
488+
}
489+
```
490+
491+
472492
## List table beforeActionButtons
473493
474494
`beforeActionButtons` allows injecting one or more compact components into the header bar of the list page, directly to the left of the default action buttons (`Create`, `Filter`, bulk actions, three‑dots menu). Use it for small inputs (quick search, toggle, status chip) rather than large panels.

adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ Here's how to add a custom action:
3131

3232
// Configure where the action appears
3333
showIn: {
34-
list: true, // Show in list view
34+
list: false, // Show in list view
35+
listThreeDotsMenu: true, // Show in three dots menu in list view
3536
showButton: true, // Show as a button
3637
showThreeDotsMenu: true, // Show in three-dots menu
3738
}
@@ -49,6 +50,7 @@ Here's how to add a custom action:
4950
- `action`: Handler function that executes when action is triggered
5051
- `showIn`: Controls where the action appears
5152
- `list`: whether to show in list view
53+
- `listThreeDotsMenu`: whether to show in three dots menu in the list view
5254
- `showButton`: whether to show as a button on show view
5355
- `showThreeDotsMenu`: when to show in the three-dots menu of show view
5456

@@ -133,6 +135,7 @@ Instead of defining an `action` handler, you can specify a `url` that the user w
133135
url: '/resource/aparts', // URL to redirect to
134136
showIn: {
135137
list: true,
138+
listThreeDotsMenu: false,
136139
showButton: true,
137140
showThreeDotsMenu: true,
138141
}
@@ -289,7 +292,7 @@ Below we wrap a “Mark as listed” action (see the original example in [Custom
289292
meta: { color: '#94a3b8', radius: 10 }
290293
//diff-add
291294
},
292-
showIn: { list: true, showButton: true, showThreeDotsMenu: true },
295+
showIn: { list: false, listThreeDotsMenu: true, showButton: true, showThreeDotsMenu: true },
293296
action: async ({ recordId }) => {
294297
await admin.resource('aparts').update(recordId, { listed: 1 });
295298
return { ok: true, successMessage: 'Marked as listed' };
@@ -368,7 +371,7 @@ Backend handler: read the payload via `extra`.
368371
{
369372
name: 'Toggle listed',
370373
icon: 'flowbite:eye-solid',
371-
showIn: { list: false, showButton: true, showThreeDotsMenu: true },
374+
showIn: { list: false, listThreeDotsMenu: false, showButton: true, showThreeDotsMenu: true },
372375
// The payload from emit('callAction', { asListed: true|false }) arrives here as `extra`
373376
customComponent: {
374377
file: '@@/ActionToggleListed.vue'

adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,25 @@ export default {
440440
![alt text](<silent refresh.gif>)
441441

442442

443+
### Move base actions out of three dots menu
443444

445+
If you want to move base record actions from the three dots menu, you can add `baseActionsAsQuickIcons`:
444446

447+
```ts
448+
options: {
449+
450+
...
451+
452+
baseActionsAsQuickIcons: ['edit'],
453+
454+
...
455+
456+
}
457+
```
458+
459+
And `edit` action will be available as quick action:
460+
461+
![alt text](<threeDotsListMenu.png>)
445462

446463

447464
9.79 KB
Loading

0 commit comments

Comments
 (0)