Skip to content
Merged

Next #627

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b0d9e41
feat: add runAction method for handling custom actions in AdminForth
kulikp1 May 18, 2026
eded8d1
feat: add documentation for programmatically starting actions with ru…
kulikp1 May 19, 2026
1b33b65
fix: remove optional type from extra parameter in AdminForth class
kulikp1 May 19, 2026
44b23a4
fix: add white-space pre-wrap style to jv-code class for better text …
kulikp1 May 21, 2026
164e97c
dev-demo: restrict allowed actions for foreign resource configuration…
NoOne7135 May 21, 2026
df095f2
Merge pull request #617 from devforth/feature/AdminForth/1619/can't-a…
ivictbor May 21, 2026
21de1d8
docs: update Markdown documentation with new images and improved visuals
kulikp1 May 22, 2026
3219cf2
fix: update classesForInput for improved styling of page size button
kulikp1 May 22, 2026
6313d84
fix: update page size button styling for consistency
kulikp1 May 22, 2026
582a599
Merge pull request #616 from devforth/feature/AdminForth/1547/https-a…
SerVitasik May 22, 2026
49b06ca
Merge pull request #619 from devforth/feature/AdminForth/1626/screens…
SerVitasik May 22, 2026
be029de
fix: update classesForInput for improved styling of page size button
kulikp1 May 25, 2026
a24e519
Merge pull request #620 from devforth/feature/AdminForth/1624/on-defa…
SerVitasik May 25, 2026
29a0493
fix: add default mode to button options in Dialog component
kulikp1 May 25, 2026
3db5920
feat: implement dynamic menu contributions and refresh functionality
NoOne7135 May 26, 2026
0a5f906
fix: update hover border color for secondary button mode
kulikp1 May 26, 2026
5040335
Merge pull request #621 from devforth/feature/AdminForth/1639/fix-hov…
SerVitasik May 27, 2026
d4fa299
fix: update background jobs image reference and add new image file
kulikp1 May 27, 2026
e45ab15
Merge pull request #622 from devforth/feature/AdminForth/1630/uncente…
SerVitasik May 27, 2026
3ef379a
fix: increase z-index for overlay in Filters component
kulikp1 May 27, 2026
feaf273
Merge pull request #623 from devforth/feature/AdminForth/1640/(((
SerVitasik May 27, 2026
73a5be6
fix: fix close dialog logic
kulikp1 May 29, 2026
50b3a06
fix: rename
kulikp1 May 29, 2026
be43d92
Merge pull request #625 from devforth/feature/AdminForth/1629/search-…
SerVitasik Jun 1, 2026
230b7f2
feat: support multiple groupBy rules and add count_distinct operation…
NoOne7135 Jun 1, 2026
41fc919
Merge branch 'main' of github.com:devforth/adminforth into next
NoOne7135 Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions adminforth/dataConnectors/baseConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { afLogger } from '../modules/logger.js';

type AdminForthFilterNode = IAdminForthSingleFilter | IAdminForthAndOrFilter;
type AdminForthFilterInput = AdminForthFilterNode | AdminForthFilterNode[];
type AggregateGroupByInput = IGroupByRule | IGroupByRule[] | undefined;
type AdminForthFilterNormalizationResult = {
ok: boolean;
error: string;
Expand Down Expand Up @@ -286,18 +287,26 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
resource: AdminForthResource,
filters: IAdminForthAndOrFilter,
aggregations: { [alias: string]: IAggregationRule },
groupBy?: IGroupByRule,
groupBy?: AggregateGroupByInput,
}): Promise<Array<{ group?: string, [key: string]: any }>> {
throw new Error('getAggregateWithOriginalTypes() not implemented for this connector.');
}

normalizeGroupByRules(groupBy?: AggregateGroupByInput): IGroupByRule[] {
return groupBy ? (Array.isArray(groupBy) ? groupBy : [groupBy]) : [];
}

getGroupByResultAlias(groupBy: IGroupByRule, index: number, total: number): string {
return groupBy.as ?? (total === 1 ? 'group' : `group${index + 1}`);
}

private validateAggregateParams(
resource: AdminForthResource,
aggregations: { [alias: string]: IAggregationRule },
groupBy?: IGroupByRule,
groupBy?: AggregateGroupByInput,
): void {
const VALID_ALIAS = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const VALID_OPERATIONS = ['sum', 'count', 'avg', 'min', 'max', 'median'];
const VALID_OPERATIONS = ['sum', 'count', 'count_distinct', 'avg', 'min', 'max', 'median'];
const VALID_TRUNCATIONS = ['day', 'week', 'month', 'year'];
const VALID_TIMEZONE = /^[a-zA-Z_\/\-\+0-9]+$/;
const columnNames = new Set(resource.dataSourceColumns.map(c => c.name));
Expand All @@ -323,20 +332,26 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
}
}

if (groupBy) {
if (groupBy.type === 'field') {
assertColumn(groupBy.field, 'GroupBy.Field');
} else if (groupBy.type === 'date_trunc') {
const g = groupBy as IGroupByDateTrunc;
for (const groupByRule of this.normalizeGroupByRules(groupBy)) {
if (groupByRule.type === 'field') {
assertColumn(groupByRule.field, 'GroupBy.Field');
if (groupByRule.as && !VALID_ALIAS.test(groupByRule.as)) {
throw new Error(`Invalid groupBy alias "${groupByRule.as}". Must match ${VALID_ALIAS}`);
}
} else if (groupByRule.type === 'date_trunc') {
const g = groupByRule as IGroupByDateTrunc;
assertColumn(g.field, 'GroupBy.DateTrunc');
if (!VALID_TRUNCATIONS.includes(g.truncation)) {
throw new Error(`Invalid truncation "${g.truncation}". Must be one of: ${VALID_TRUNCATIONS.join(', ')}`);
}
if (g.timezone && !VALID_TIMEZONE.test(g.timezone)) {
throw new Error(`Invalid timezone "${g.timezone}". Must be a valid IANA timezone name`);
}
if (g.as && !VALID_ALIAS.test(g.as)) {
throw new Error(`Invalid groupBy alias "${g.as}". Must match ${VALID_ALIAS}`);
}
} else {
throw new Error(`Unknown groupBy type "${(groupBy as any).type}"`);
throw new Error(`Unknown groupBy type "${(groupByRule as any).type}"`);
}
}
}
Expand All @@ -345,7 +360,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
resource: AdminForthResource,
filters: IAdminForthAndOrFilter,
aggregations: { [alias: string]: IAggregationRule },
groupBy?: IGroupByRule,
groupBy?: AggregateGroupByInput,
}): Promise<Array<{ group?: string, [key: string]: any }>> {
this.validateAggregateParams(resource, aggregations, groupBy);

Expand Down
29 changes: 16 additions & 13 deletions adminforth/dataConnectors/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,16 +451,19 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
resource: AdminForthResource;
filters: IAdminForthAndOrFilter;
aggregations: { [alias: string]: IAggregationRule };
groupBy?: IGroupByRule;
groupBy?: IGroupByRule | IGroupByRule[];
}): Promise <Array<{ group?: string, [key: string]: any }>> {

const tableName = `${this.dbName}.${resource.table}`;

const selectParts: string[] = [];
let groupExpr: string | null = null;
const groupExprs: string[] = [];
const groupByRules = this.normalizeGroupByRules(groupBy);

if (groupBy?.type === 'date_trunc') {
const g = groupBy as IGroupByDateTrunc;
for (const [index, groupByRule] of groupByRules.entries()) {
let groupExpr: string;
if (groupByRule.type === 'date_trunc') {
const g = groupByRule as IGroupByDateTrunc;
const tz = g.timezone ?? 'UTC';

const field = `toTimeZone(${g.field}, '${tz}')`;
Expand All @@ -471,18 +474,18 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
case 'week': groupExpr = `toDate(toStartOfWeek(${field}))`; break;
case 'year': groupExpr = `toDate(toStartOfYear(${field}))`; break;
}

selectParts.push(`${groupExpr} AS \`group\``);

} else if (groupBy?.type === 'field') {
const g = groupBy as IGroupByField;
} else {
const g = groupByRule as IGroupByField;
groupExpr = `${g.field}`;
selectParts.push(`${groupExpr} AS \`group\``);
}
groupExprs.push(groupExpr);
selectParts.push(`${groupExpr} AS \`${this.getGroupByResultAlias(groupByRule, index, groupByRules.length)}\``);
}

for (const [alias, rule] of Object.entries(aggregations)) {
switch (rule.operation) {
case 'count': selectParts.push(`count() AS \`${alias}\``); break;
case 'count_distinct': selectParts.push(`uniqExact(${rule.field}) AS \`${alias}\``); break;
case 'sum': selectParts.push(`sum(${rule.field}) AS \`${alias}\``); break;
case 'avg': selectParts.push(`avg(${rule.field}) AS \`${alias}\``); break;
case 'min': selectParts.push(`min(${rule.field}) AS \`${alias}\``); break;
Expand All @@ -495,8 +498,8 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth

let query = `SELECT ${selectParts.join(', ')} FROM ${tableName} ${where}`;

if (groupExpr) {
query += ` GROUP BY ${groupExpr} ORDER BY ${groupExpr} ASC`;
if (groupExprs.length) {
query += ` GROUP BY ${groupExprs.join(', ')} ORDER BY ${groupExprs.join(', ')} ASC`;
}

const result = await this.client.query({
Expand Down Expand Up @@ -664,4 +667,4 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
}
}

export default ClickhouseConnector;
export default ClickhouseConnector;
56 changes: 32 additions & 24 deletions adminforth/dataConnectors/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,22 +310,26 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
resource: AdminForthResource;
filters: IAdminForthAndOrFilter;
aggregations: { [alias: string]: IAggregationRule };
groupBy?: IGroupByRule;
groupBy?: IGroupByRule | IGroupByRule[];
}): Promise<Array<{ group?: string, [key: string]: any }>> {

const collection = this.client.db().collection(resource.table);

const match = filters?.subFilters?.length ? this.getFilterQuery(resource, filters) : {};

const groupByRules = this.normalizeGroupByRules(groupBy);
let groupId: any = null;

if (groupBy?.type === 'field') {
const g = groupBy as IGroupByField;
groupId = `$${g.field}`;
if (groupByRules.length) {
groupId = {};
}

if (groupBy?.type === 'date_trunc') {
const g = groupBy as IGroupByDateTrunc;
for (const [index, groupByRule] of groupByRules.entries()) {
const alias = this.getGroupByResultAlias(groupByRule, index, groupByRules.length);
if (groupByRule.type === 'field') {
const g = groupByRule as IGroupByField;
groupId[alias] = `$${g.field}`;
continue;
}
const g = groupByRule as IGroupByDateTrunc;
const tz = g.timezone ?? 'UTC';
const dateTruncSpec: any = {
date: `$${g.field}`,
Expand All @@ -335,7 +339,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
if (g.truncation === 'week') {
dateTruncSpec.startOfWeek = 'Mon';
}
groupId = { $dateTrunc: dateTruncSpec };
groupId[alias] = { $dateTrunc: dateTruncSpec };
}

const groupStage: Record<string, any> = {
Expand All @@ -345,6 +349,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
for (const [alias, rule] of Object.entries(aggregations)) {
switch (rule.operation) {
case 'count': groupStage[alias] = { $sum: 1 }; break;
case 'count_distinct': groupStage[alias] = { $addToSet: `$${rule.field}` }; break;
case 'sum': groupStage[alias] = { $sum: { $toDouble: `$${rule.field}` } }; break;
case 'avg': groupStage[alias] = { $avg: { $toDouble: `$${rule.field}` } }; break;
case 'min': groupStage[alias] = { $min: { $toDouble: `$${rule.field}` } }; break;
Expand All @@ -364,23 +369,26 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
pipeline.push({
$project: {
_id: 0,
group: !groupBy ? "$$REMOVE" : (groupBy.type === 'date_trunc' ? {
$cond: {
if: { $eq: [{ $type: "$_id" }, "date"] },
then: {
$dateToString: {
format: "%Y-%m-%d",
date: "$_id",
timezone: (groupBy as IGroupByDateTrunc).timezone ?? 'UTC'
}
},
else: "$_id"
}
} : "$_id"),
...Object.fromEntries(groupByRules.map((groupByRule, index) => {
const alias = this.getGroupByResultAlias(groupByRule, index, groupByRules.length);
return [alias, groupByRule.type === 'date_trunc' ? {
$cond: {
if: { $eq: [{ $type: `$_id.${alias}` }, "date"] },
then: {
$dateToString: {
format: "%Y-%m-%d",
date: `$_id.${alias}`,
timezone: (groupByRule as IGroupByDateTrunc).timezone ?? 'UTC'
}
},
else: `$_id.${alias}`
}
} : `$_id.${alias}`];
})),
...Object.fromEntries(
Object.keys(groupStage)
.filter(k => k !== '_id')
.map(k => [k, `$${k}`])
.map(k => [k, aggregations[k]?.operation === 'count_distinct' ? { $size: `$${k}` } : `$${k}`])
),
},
});
Expand Down Expand Up @@ -521,4 +529,4 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
}
}

export default MongoConnector;
export default MongoConnector;
74 changes: 41 additions & 33 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,37 +345,42 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
resource: AdminForthResource;
filters: IAdminForthAndOrFilter;
aggregations: { [alias: string]: IAggregationRule };
groupBy?: IGroupByRule;
groupBy?: IGroupByRule | IGroupByRule[];
}): Promise<Array<{ group?: string, [key: string]: any }>> {
const tableName = resource.table;
const selectParts: string[] = [];
const medianFields: { alias: string; field: string }[] = [];
let groupExpr: string | null = null;

if (groupBy?.type === 'field') {
groupExpr = `\`${groupBy.field}\``;
selectParts.push(`${groupExpr} AS \`group\``);
} else if (groupBy?.type === 'date_trunc') {
const g = groupBy as IGroupByDateTrunc;
const tz = g.timezone ?? 'UTC';
if (!/^[A-Za-z0-9/_+\-]+$/.test(tz)) {
throw new Error(`Invalid timezone value: ${tz}`);
}
const innerExpr = `COALESCE(CONVERT_TZ(\`${g.field}\`, 'UTC', '${tz}'), \`${g.field}\`)`;
switch (g.truncation) {
case 'day': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-%m-%d')`; break;
case 'month': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-%m-01')`; break;
case 'year': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-01-01')`; break;
case 'week': groupExpr = `DATE_FORMAT(DATE_SUB(${innerExpr}, INTERVAL WEEKDAY(${innerExpr}) DAY), '%Y-%m-%d')`; break;
const groupExprs: string[] = [];
const groupAliases: string[] = [];
const groupByRules = this.normalizeGroupByRules(groupBy);

for (const [index, groupByRule] of groupByRules.entries()) {
let groupExpr: string;
if (groupByRule.type === 'field') {
groupExpr = `\`${groupByRule.field}\``;
} else {
const g = groupByRule as IGroupByDateTrunc;
const tz = g.timezone ?? 'UTC';
const innerExpr = `COALESCE(CONVERT_TZ(\`${g.field}\`, 'UTC', '${tz}'), \`${g.field}\`)`;
switch (g.truncation) {
case 'day': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-%m-%d')`; break;
case 'month': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-%m-01')`; break;
case 'year': groupExpr = `DATE_FORMAT(${innerExpr}, '%Y-01-01')`; break;
case 'week': groupExpr = `DATE_FORMAT(DATE_SUB(${innerExpr}, INTERVAL WEEKDAY(${innerExpr}) DAY), '%Y-%m-%d')`; break;
}
}
selectParts.push(`${groupExpr} AS \`group\``);
const groupAlias = this.getGroupByResultAlias(groupByRule, index, groupByRules.length);
groupExprs.push(groupExpr);
groupAliases.push(groupAlias);
selectParts.push(`${groupExpr} AS \`${groupAlias}\``);
}

for (const [alias, rule] of Object.entries(aggregations)) {
const f = `\`${rule.field}\``;
switch (rule.operation) {
case 'sum': selectParts.push(`SUM(${f}) AS \`${alias}\``); break;
case 'count': selectParts.push(`COUNT(*) AS \`${alias}\``); break;
case 'count_distinct': selectParts.push(`COUNT(DISTINCT ${f}) AS \`${alias}\``); break;
case 'avg': selectParts.push(`AVG(${f}) AS \`${alias}\``); break;
case 'min': selectParts.push(`MIN(${f}) AS \`${alias}\``); break;
case 'max': selectParts.push(`MAX(${f}) AS \`${alias}\``); break;
Expand All @@ -389,10 +394,10 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS

// Run non-median aggregations
let rows: AggRow[] = [];
const hasNonMedian = selectParts.length > (groupExpr ? 1 : 0);
const hasNonMedian = selectParts.length > groupExprs.length;
if (hasNonMedian) {
let query = `SELECT ${selectParts.join(', ')} FROM \`${tableName}\` ${where}`;
if (groupExpr) query += ` GROUP BY ${groupExpr} ORDER BY ${groupExpr} ASC`;
if (groupExprs.length) query += ` GROUP BY ${groupExprs.join(', ')} ORDER BY ${groupExprs.join(', ')} ASC`;
dbLogger.trace(`🪲📜 MySQL AGG Q: ${query} values: ${JSON.stringify(filterValues)}`);
const [result] = await this.client.execute(query, filterValues);
rows = result as AggRow[];
Expand All @@ -404,18 +409,20 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
const nullGuard = where ? `${where} AND ${f} IS NOT NULL` : `WHERE ${f} IS NOT NULL`;

let medianQuery: string;
if (groupExpr) {
if (groupExprs.length) {
const groupSelect = groupExprs.map((expr, index) => `${expr} AS \`${groupAliases[index]}\``).join(', ');
const groupColumns = groupAliases.map(alias => `\`${alias}\``).join(', ');
medianQuery = `
SELECT \`group\`, AVG(${f}) AS \`${alias}\`
SELECT ${groupColumns}, AVG(${f}) AS \`${alias}\`
FROM (
SELECT ${groupExpr} AS \`group\`, ${f},
ROW_NUMBER() OVER (PARTITION BY ${groupExpr} ORDER BY ${f}) AS rn,
COUNT(*) OVER (PARTITION BY ${groupExpr}) AS cnt
SELECT ${groupSelect}, ${f},
ROW_NUMBER() OVER (PARTITION BY ${groupExprs.join(', ')} ORDER BY ${f}) AS rn,
COUNT(*) OVER (PARTITION BY ${groupExprs.join(', ')}) AS cnt
FROM \`${tableName}\` ${nullGuard}
) t
WHERE rn IN (FLOOR((cnt + 1) / 2.0), CEIL((cnt + 1) / 2.0))
GROUP BY \`group\`
ORDER BY \`group\` ASC
GROUP BY ${groupColumns}
ORDER BY ${groupColumns} ASC
`;
} else {
medianQuery = `
Expand All @@ -434,13 +441,14 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
const [medianResult] = await this.client.execute(medianQuery, filterValues);
const medianRows = medianResult as AggRow[];

if (groupExpr) {
if (groupExprs.length) {
const groupKey = (row: AggRow) => groupAliases.map(alias => String(row[alias])).join('\u0000');
if (rows.length === 0) {
rows = medianRows.map((r) => ({ group: r.group, [alias]: r[alias] }));
rows = medianRows.map((r) => ({ ...Object.fromEntries(groupAliases.map(groupAlias => [groupAlias, r[groupAlias]])), [alias]: r[alias] }));
} else {
const byGroup = new Map(medianRows.map((r) => [String(r.group), r[alias]]));
const byGroup = new Map(medianRows.map((r) => [groupKey(r), r[alias]]));
for (const row of rows) {
row[alias] = byGroup.get(String(row.group)) ?? null;
row[alias] = byGroup.get(groupKey(row)) ?? null;
}
}
} else {
Expand Down Expand Up @@ -557,4 +565,4 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
}
}

export default MysqlConnector;
export default MysqlConnector;
Loading