Skip to content

Commit 6e59bd2

Browse files
committed
Merge branch 'main' of github.com:devforth/adminforth
2 parents d567e7a + 4506aa0 commit 6e59bd2

22 files changed

Lines changed: 606 additions & 155 deletions

‎adminforth/dataConnectors/baseConnector.ts‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,40 @@ type AdminForthFilterNormalizationResult = {
2525
normalizedFilters?: AdminForthFilterInput;
2626
};
2727

28+
function polymorphicFilterableFields(resource: AdminForthResource): Set<string> | null {
29+
const resources = global.adminforth?.config?.resources;
30+
if (!resources) {
31+
return null;
32+
}
33+
const siblingResourceIds = new Set<string>();
34+
let isTarget = false;
35+
for (const res of resources) {
36+
for (const col of res.dataSourceColumns) {
37+
const polymorphicResources = col.foreignResource?.polymorphicResources;
38+
if (polymorphicResources?.some((pr) => pr.resourceId === resource.resourceId)) {
39+
isTarget = true;
40+
for (const pr of polymorphicResources) {
41+
if (pr.resourceId) {
42+
siblingResourceIds.add(pr.resourceId);
43+
}
44+
}
45+
}
46+
}
47+
}
48+
if (!isTarget) {
49+
return null;
50+
}
51+
const fields = new Set<string>();
52+
for (const res of resources) {
53+
if (res.resourceId === resource.resourceId || siblingResourceIds.has(res.resourceId)) {
54+
for (const col of res.dataSourceColumns) {
55+
fields.add(col.name);
56+
}
57+
}
58+
}
59+
return fields;
60+
}
61+
2862
async function publishShowPageUpdate(resource: AdminForthResource, recordId: string, updates: Record<string, any>) {
2963
await global.adminforth.websocket.publish(`/showPage/${resource.resourceId}/${String(recordId)}`,
3064
{
@@ -271,23 +305,16 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
271305
}
272306

273307
const similar = suggestIfTypo(resource.dataSourceColumns.map((col) => col.name), normalizedFilter.field);
274-
275-
let isPolymorphicTarget = false;
276-
if (global.adminforth?.config?.resources) {
277-
isPolymorphicTarget = global.adminforth.config.resources.some(res =>
278-
res.dataSourceColumns.some(col =>
279-
col.foreignResource?.polymorphicResources?.some(pr =>
280-
pr.resourceId === resource.resourceId
281-
)
282-
)
283-
);
284-
}
285-
if (isPolymorphicTarget) {
286-
afLogger.trace(`⚠️ Field '${normalizedFilter.field}' not found in polymorphic target resource '${resource.resourceId}', allowing query to proceed.`);
308+
309+
const polymorphicFields = polymorphicFilterableFields(resource);
310+
const rightFieldAllowed = !comparingWithRightField
311+
|| resource.dataSourceColumns.some((col) => col.name == normalizedFilter.rightField)
312+
|| !!polymorphicFields?.has(normalizedFilter.rightField as string);
313+
if (polymorphicFields?.has(normalizedFilter.field) && rightFieldAllowed) {
314+
afLogger.trace(`Field '${normalizedFilter.field}' not in resource '${resource.resourceId}' but valid on a polymorphic sibling, allowing query to proceed.`);
287315
return { ok: true, error: '', normalizedFilters: normalizedFilter };
288-
} else {
289-
throw new Error(`Field '${normalizedFilter.field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
290316
}
317+
throw new Error(`Field '${normalizedFilter.field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
291318
}
292319
// value normalization
293320
if (comparingWithRightField) {

‎adminforth/index.ts‎

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import OperationalResource from './modules/operationalResource.js';
4141
import SocketBroker from './modules/socketBroker.js';
4242
import { afLogger } from './modules/logger.js';
4343
import { normalizeRecordValues } from './modules/columnValueNormalizer.js';
44+
import { isEmptyColumnValue, runRecordValidators, validateColumnValue } from './modules/columnValueValidator.js';
4445
import { createRequire } from 'module';
4546
import path from 'path';
4647
import { pathToFileURL } from 'url';
@@ -452,33 +453,11 @@ class AdminForth implements IAdminForth {
452453
}
453454
}
454455

455-
// check if record with minValue or maxValue is within limits
456-
for (const column of resource.columns.filter((col) => col.name in record
457-
&& ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type)
458-
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
459-
if (column.isArray?.enabled) {
460-
const error = record[column.name].reduce((err, item) => {
461-
if (err) return err;
462-
463-
if (column.minValue !== undefined && item < column.minValue) {
464-
return `Value in "${column.name}" must be greater than ${column.minValue}`;
465-
}
466-
if (column.maxValue !== undefined && item > column.maxValue) {
467-
return `Value in "${column.name}" must be less than ${column.maxValue}`;
468-
}
469-
470-
return null;
471-
}, null);
472-
if (error) {
473-
return error;
474-
}
475-
} else {
476-
if (column.minValue !== undefined && record[column.name] && record[column.name] < column.minValue) {
477-
return `Value in "${column.name}" must be greater than ${column.minValue}`;
478-
}
479-
if (column.maxValue !== undefined && record[column.name] && record[column.name] > column.maxValue) {
480-
return `Value in "${column.name}" must be less than ${column.maxValue}`;
481-
}
456+
// empty values are checked by required rules
457+
for (const column of resource.columns.filter((col) => col.name in record && !isEmptyColumnValue(col, record[col.name]))) {
458+
const error = validateColumnValue(column, record[column.name]);
459+
if (error) {
460+
return error;
482461
}
483462
}
484463

@@ -802,7 +781,8 @@ class AdminForth implements IAdminForth {
802781

803782
normalizeRecordValues(resource, record);
804783

805-
const err = this.validateRecordValues(resource, record, 'create');
784+
const err = this.validateRecordValues(resource, record, 'create')
785+
?? await runRecordValidators(resource, record, record, this);
806786
if (err) {
807787
return { error: err };
808788
}
@@ -894,7 +874,8 @@ class AdminForth implements IAdminForth {
894874
}
895875
const { resource, recordId, oldRecord, adminUser, extra, updates } = params;
896876
normalizeRecordValues(resource, updates);
897-
const err = this.validateRecordValues(resource, updates, 'edit');
877+
const err = this.validateRecordValues(resource, updates, 'edit')
878+
?? await runRecordValidators(resource, updates, { ...oldRecord, ...updates }, this);
898879
if (err) {
899880
return { error: err };
900881
}

‎adminforth/modules/codeInjector.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ class CodeInjector implements ICodeInjector {
404404

405405
const env = {
406406
VITE_ADMINFORTH_PUBLIC_PATH: this.adminforth.config.baseUrl,
407+
VITE_ADMINFORTH_BRAND_SLUG: this.adminforth.config.customization.brandNameSlug,
407408
FORCE_COLOR: '1',
408409
...process.env,
409410
...envOverrides,
@@ -1538,6 +1539,7 @@ class CodeInjector implements ICodeInjector {
15381539
}
15391540
const env = {
15401541
VITE_ADMINFORTH_PUBLIC_PATH: this.adminforth.config.baseUrl,
1542+
VITE_ADMINFORTH_BRAND_SLUG: this.adminforth.config.customization.brandNameSlug,
15411543
FORCE_COLOR: '1',
15421544
...process.env,
15431545
};

‎adminforth/modules/columnValueNormalizer.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ export function normalizeColumnValue(column: AdminForthResourceColumn, value: an
44
return column.normalize ? column.normalize(value) : value;
55
}
66

7+
function toLowerCase(value: any): any {
8+
if (Array.isArray(value)) {
9+
return value.map(toLowerCase);
10+
}
11+
return typeof value === 'string' ? value.toLowerCase() : value;
12+
}
13+
714
export function normalizeRecordValues(resource: AdminForthResource, record: Record<string, any>): void {
815
for (const column of resource.columns) {
916
if (column.name in record) {
10-
record[column.name] = normalizeColumnValue(column, record[column.name]);
17+
const value = column.enforceLowerCase ? toLowerCase(record[column.name]) : record[column.name];
18+
record[column.name] = normalizeColumnValue(column, value);
1119
}
1220
}
1321
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AdminForthDataTypes } from '../types/Common.js';
2+
import type { AdminForthResource, AdminForthResourceColumn, IAdminForth } from '../types/Back.js';
3+
4+
const LENGTH_LIMITED_TYPES = [AdminForthDataTypes.STRING, AdminForthDataTypes.TEXT];
5+
const VALUE_LIMITED_TYPES = [AdminForthDataTypes.INTEGER, AdminForthDataTypes.DECIMAL, AdminForthDataTypes.FLOAT];
6+
7+
function isEmptyValue(value: any): boolean {
8+
return value === undefined || value === null || value === '';
9+
}
10+
11+
// connectors discover an absent limit as null
12+
function isLimitSet(limit: number | null | undefined): boolean {
13+
return limit !== undefined && limit !== null;
14+
}
15+
16+
/**
17+
* Same emptiness as the create/edit form uses for required columns
18+
*/
19+
export function isEmptyColumnValue(column: AdminForthResourceColumn, value: any): boolean {
20+
return isEmptyValue(value) || (!!column.isArray?.enabled && value.length === 0);
21+
}
22+
23+
function validateItemValue(column: AdminForthResourceColumn, type: AdminForthDataTypes, value: any): string | null {
24+
if (LENGTH_LIMITED_TYPES.includes(type)) {
25+
if (isLimitSet(column.maxLength) && value.length > column.maxLength) {
26+
return `Value in "${column.name}" must not be longer than ${column.maxLength} characters`;
27+
}
28+
if (isLimitSet(column.minLength) && value.length < column.minLength) {
29+
return `Value in "${column.name}" must not be shorter than ${column.minLength} characters`;
30+
}
31+
}
32+
if (VALUE_LIMITED_TYPES.includes(type)) {
33+
if (isLimitSet(column.minValue) && value < column.minValue) {
34+
return `Value in "${column.name}" must be greater than ${column.minValue}`;
35+
}
36+
if (isLimitSet(column.maxValue) && value > column.maxValue) {
37+
return `Value in "${column.name}" must be less than ${column.maxValue}`;
38+
}
39+
}
40+
if (column.enum && !column.enum.some((item) => item.value === value)) {
41+
return `Value in "${column.name}" must be one of: ${column.enum.map((item) => item.value).join(', ')}`;
42+
}
43+
return null;
44+
}
45+
46+
/**
47+
* Checks a non-empty column value against length and value limits, enum and array rules
48+
*/
49+
export function validateColumnValue(column: AdminForthResourceColumn, value: any): string | null {
50+
if (!column.isArray?.enabled) {
51+
return validateItemValue(column, column.type, value);
52+
}
53+
if (value.some(isEmptyValue)) {
54+
return `Array in "${column.name}" cannot contain empty items`;
55+
}
56+
if (!column.isArray.allowDuplicateItems && new Set(value).size !== value.length) {
57+
return `Array in "${column.name}" cannot contain duplicate items`;
58+
}
59+
for (const item of value) {
60+
const error = validateItemValue(column, column.isArray.itemType, item);
61+
if (error) {
62+
return error;
63+
}
64+
}
65+
return null;
66+
}
67+
68+
/**
69+
* Runs custom `validator` callbacks of the column and returns the first failed result
70+
*/
71+
export async function runColumnValidators(
72+
column: AdminForthResourceColumn,
73+
value: any,
74+
record: any,
75+
adminforth: IAdminForth,
76+
): Promise<{ isValid: false, message?: string } | null> {
77+
for (const val of column.validation ?? []) {
78+
if (val.validator) {
79+
const result = await val.validator(value, record, adminforth);
80+
if (typeof result === 'object' && result.isValid === false) {
81+
return { isValid: false, message: result.message };
82+
}
83+
}
84+
}
85+
return null;
86+
}
87+
88+
/**
89+
* Runs custom validators of the columns present in `values`, `record` is the whole record they are passed
90+
*/
91+
export async function runRecordValidators(
92+
resource: AdminForthResource,
93+
values: any,
94+
record: any,
95+
adminforth: IAdminForth,
96+
): Promise<string | null> {
97+
for (const column of resource.columns.filter((col) => col.name in values)) {
98+
const failed = await runColumnValidators(column, values[column.name], record, adminforth);
99+
if (failed) {
100+
return failed.message ?? `Value in "${column.name}" is invalid`;
101+
}
102+
}
103+
return null;
104+
}

0 commit comments

Comments
 (0)