diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 0879767d3..39bcb4777 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -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; @@ -286,18 +287,26 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: AggregateGroupByInput, }): Promise> { 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)); @@ -323,11 +332,14 @@ 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(', ')}`); @@ -335,8 +347,11 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon 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}"`); } } } @@ -345,7 +360,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: AggregateGroupByInput, }): Promise> { this.validateAggregateParams(resource, aggregations, groupBy); diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index 5c8333a69..f714a5b06 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -451,16 +451,19 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth resource: AdminForthResource; filters: IAdminForthAndOrFilter; aggregations: { [alias: string]: IAggregationRule }; - groupBy?: IGroupByRule; + groupBy?: IGroupByRule | IGroupByRule[]; }): Promise > { 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}')`; @@ -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; @@ -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({ @@ -664,4 +667,4 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth } } -export default ClickhouseConnector; \ No newline at end of file +export default ClickhouseConnector; diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 602608c50..2ad7f0926 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -310,22 +310,26 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS resource: AdminForthResource; filters: IAdminForthAndOrFilter; aggregations: { [alias: string]: IAggregationRule }; - groupBy?: IGroupByRule; + groupBy?: IGroupByRule | IGroupByRule[]; }): Promise> { 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}`, @@ -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 = { @@ -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; @@ -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}`]) ), }, }); @@ -521,4 +529,4 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS } } -export default MongoConnector; \ No newline at end of file +export default MongoConnector; diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index a1ff3b089..9150d02a6 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -345,30 +345,34 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS resource: AdminForthResource; filters: IAdminForthAndOrFilter; aggregations: { [alias: string]: IAggregationRule }; - groupBy?: IGroupByRule; + groupBy?: IGroupByRule | IGroupByRule[]; }): Promise> { 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)) { @@ -376,6 +380,7 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS 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; @@ -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[]; @@ -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 = ` @@ -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 { @@ -557,4 +565,4 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS } } -export default MysqlConnector; \ No newline at end of file +export default MysqlConnector; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 75a7a13d7..d86c8b85e 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -388,14 +388,17 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: IGroupByRule | IGroupByRule[], }): Promise> { const tableName = 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 col = resource.dataSourceColumns.find(c => c.name === g.field); const hasTZ = (col as any)?._baseTypeDebug?.includes('with time zone'); @@ -404,17 +407,19 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa : `"${g.field}" AT TIME ZONE 'UTC' AT TIME ZONE '${tz}'`; const fieldExpr = `DATE_TRUNC('${g.truncation}', ${innerExpr})`; groupExpr = `TO_CHAR(${fieldExpr}, 'YYYY-MM-DD')`; - 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 'sum': selectParts.push(`SUM("${rule.field}") AS "${alias}"`); break; case 'count': selectParts.push(`COUNT(*) AS "${alias}"`); break; + case 'count_distinct': selectParts.push(`COUNT(DISTINCT "${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; case 'max': selectParts.push(`MAX("${rule.field}") AS "${alias}"`); break; @@ -425,8 +430,8 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const { sql: where, values: filterValues } = this.whereClauseAndValues(resource, filters); 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(`πŸͺ²πŸ“œ PG AGG Q: ${query}, params: ${JSON.stringify(filterValues)}`); @@ -536,4 +541,4 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa } } -export default PostgresConnector; \ No newline at end of file +export default PostgresConnector; diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index fa15a1f2d..9dd546763 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -347,26 +347,29 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: IGroupByRule | IGroupByRule[], }): Promise> { const tableName = resource.table; const where = this.whereClause(filters); const filterValues = this.getFilterParams(filters); + const groupByRules = this.normalizeGroupByRules(groupBy); - if (!groupBy || groupBy.type === 'field') { + if (groupByRules.every(g => g.type === 'field')) { const selectParts: string[] = []; - let groupExpr: string | null = null; + const groupExprs: string[] = []; - if (groupBy?.type === 'field') { - const g = groupBy as IGroupByField; - groupExpr = `"${g.field}"`; - selectParts.push(`${groupExpr} AS "group"`); + for (const [index, groupByRule] of groupByRules.entries()) { + const g = groupByRule as IGroupByField; + const groupExpr = `"${g.field}"`; + 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 'sum': selectParts.push(`SUM("${rule.field}") AS "${alias}"`); break; case 'count': selectParts.push(`COUNT(*) AS "${alias}"`); break; + case 'count_distinct': selectParts.push(`COUNT(DISTINCT "${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; case 'max': selectParts.push(`MAX("${rule.field}") AS "${alias}"`); break; @@ -375,17 +378,13 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData } 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(`πŸͺ²πŸ“œ SQLITE AGG Q: ${query}, params: ${JSON.stringify(filterValues)}`); return this.client.prepare(query).all([...filterValues]); } - const g = groupBy as IGroupByDateTrunc; - const timezone = g.timezone ?? 'UTC'; - const col = resource.dataSourceColumns.find(c => c.name === g.field); - const underlineType = col?._underlineType ?? 'varchar'; - - const neededFields = new Set([g.field]); + const neededFields = new Set(); + for (const groupByRule of groupByRules) neededFields.add(groupByRule.field); for (const rule of Object.values(aggregations)) { if (rule.field) neededFields.add(rule.field); } @@ -395,19 +394,34 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const rawRows: any[] = this.client.prepare(rawQuery).all([...filterValues]); const groups = new Map(); + const groupValues = new Map>(); for (const row of rawRows) { - const key = this._dateGroupKey(row[g.field], underlineType, g.truncation, timezone); + const values: Record = {}; + for (const [index, groupByRule] of groupByRules.entries()) { + const alias = this.getGroupByResultAlias(groupByRule, index, groupByRules.length); + if (groupByRule.type === 'date_trunc') { + const g = groupByRule as IGroupByDateTrunc; + const col = resource.dataSourceColumns.find(c => c.name === g.field); + values[alias] = this._dateGroupKey(row[g.field], col?._underlineType ?? 'varchar', g.truncation, g.timezone ?? 'UTC'); + } else { + values[alias] = row[(groupByRule as IGroupByField).field]; + } + } + const key = JSON.stringify(values); if (!groups.has(key)) groups.set(key, []); + if (!groupValues.has(key)) groupValues.set(key, values); groups.get(key)!.push(row); } - const results: Array<{ group: string, [key: string]: any }> = []; + const results: Array<{ [key: string]: any }> = []; for (const [groupKey, rows] of groups) { - const result: { group: string, [key: string]: any } = { group: groupKey }; + const result: { [key: string]: any } = { ...groupValues.get(groupKey) }; for (const [alias, rule] of Object.entries(aggregations)) { - const nums = rule.field ? rows.map(r => Number(r[rule.field!] ?? 0)) : []; + const values = rule.field ? rows.map(r => r[rule.field!]).filter(v => v !== null && v !== undefined) : []; + const nums = values.map(v => Number(v)); switch (rule.operation) { case 'count': result[alias] = rows.length; break; + case 'count_distinct': result[alias] = new Set(values).size; break; case 'sum': result[alias] = nums.reduce((s, v) => s + v, 0); break; case 'avg': result[alias] = nums.reduce((s, x) => s + x, 0) / nums.length; break; case 'min': result[alias] = Math.min(...nums); break; @@ -425,7 +439,8 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData results.push(result); } - return results.sort((a, b) => a.group.localeCompare(b.group)); + const sortAliases = groupByRules.map((rule, index) => this.getGroupByResultAlias(rule, index, groupByRules.length)); + return results.sort((a, b) => sortAliases.map(alias => String(a[alias]).localeCompare(String(b[alias]))).find(result => result !== 0) ?? 0); } async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { @@ -530,4 +545,4 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData } } -export default SQLiteConnector; \ No newline at end of file +export default SQLiteConnector; diff --git a/adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md b/adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md index 5eb502171..f20b9a282 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md @@ -360,3 +360,46 @@ Backend handler: read the payload via `extra`. Notes: - If you don’t emit a payload, the default behavior is used by the UI (e.g., in lists the current row context is used). When you do provide a payload, it will be forwarded to the backend as `extra` for your action handler. - You can combine default context with your own payload by merging before emitting, for example: `emit('callAction', { ...row, asListed: true })` if your component has access to the row object. + +## Start actions programmatically +You can execute resource actions manually using adminforth.runAction(). This is useful inside hooks, plugins, cron jobs, custom endpoints, or any backend automation. + +```ts title="./resources/apartments.ts" +actions: [ + { + //diff-add + id: 'testToggle listedAction', + name: 'Toggle listed', + icon: 'flowbite:eye-solid', + ... + } +] +``` +Then execute it from a hook for example: + +```ts title="./resources/apartments.ts" +hooks: { + ... + afterSave: async ({ record, adminUser, resource, adminforth }: { record: any, adminUser: AdminUser, resource: AdminForthResource, adminforth: any }) => { + + await adminforth.runAction({ + actionId: 'Toggle listed', + resourceId: resource.resourceId, + recordId: record.id, + adminUser, + }); + + return { ok: true }; + }, + }, +``` + +runAction() automatically: +- finds the resource +- finds the action +- checks permissions via allowed +- executes the action handler +- passes full action context (recordId, adminUser, extra, etc.) + +> ☝️ runAction() is not limited to hooks β€” you can call it anywhere you have access to the AdminForth instance. + diff --git a/adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md b/adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md index 3d7056963..5f7402119 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md @@ -57,6 +57,131 @@ E.g. create group "Blog" with Items who link to resource "posts" and "categories If it is rare Group you can make it `open: false` so it would not take extra space in menu, but admin users will be able to open it by clicking on the group name. +## Adding menu items from plugins + +Plugins can add top-level menu items without mutating the user-defined `config.menu`. This keeps the application menu owned by the app configuration, while plugins can contribute their own entries. + +Use `registerMenuContribution` from a plugin's `modifyResourceConfig`: + +```ts title='./plugins/adminforth-dashboard/index.ts' +async modifyResourceConfig(adminforth, resourceConfig) { + super.modifyResourceConfig(adminforth, resourceConfig); + + adminforth.registerMenuContribution({ + item: { + itemId: 'dashboard', + type: 'page', + label: 'Dashboard', + icon: 'flowbite:chart-pie-solid', + path: '/dashboard', + component: this.componentPath('Dashboard.vue'), + }, + placement: { before: { resourceId: 'adminuser' } }, + }); +} +``` + +Supported placements: + +```ts +adminforth.registerMenuContribution({ + item: { + itemId: 'dashboard', + type: 'page', + label: 'Dashboard', + path: '/dashboard', + component: this.componentPath('Dashboard.vue'), + }, + placement: { position: 'first' }, +}); + +adminforth.registerMenuContribution({ + item: { + itemId: 'reports', + type: 'page', + label: 'Reports', + path: '/reports', + component: this.componentPath('Reports.vue'), + }, + placement: { after: { resourceId: 'orders' } }, +}); +``` + +`placement` can be: + +- `{ position: 'first' }` +- `{ position: 'last' }` +- `{ before: 'usersMenuItemId' }` +- `{ after: 'usersMenuItemId' }` +- `{ before: { itemId: 'usersMenuItemId' } }` +- `{ after: { resourceId: 'adminuser' } }` +- `{ before: { path: '/reports' } }` + +If placement is omitted, or if the target item is not found, AdminForth appends the contributed item to the end of the top-level menu. + +Plugin menu contributions are additive only: + +- user-defined `config.menu` is not changed +- plugins cannot remove or edit existing menu items through this API +- contributed `itemId` must not duplicate an existing top-level menu item +- this first version inserts only top-level menu items + +### Dynamic menu items from plugin state + +If a plugin needs to add menu items at runtime, for example after a user clicks a button and creates a new dashboard, register a menu contribution provider. AdminForth calls providers every time it fetches the menu. + +```ts title='./plugins/adminforth-dashboard/index.ts' +async modifyResourceConfig(adminforth, resourceConfig) { + super.modifyResourceConfig(adminforth, resourceConfig); + + adminforth.registerMenuContributionProvider(async ({ adminUser, adminforth }) => { + const dashboards = await adminforth.resource('dashboards').list(); + + return [ + { + item: { + itemId: 'dashboardsMenu', + type: 'group', + label: 'Dashboards', + icon: 'flowbite:chart-pie-solid', + children: dashboards.map((dashboard) => ({ + itemId: `dashboard-${dashboard.id}`, + type: 'page', + label: dashboard.name, + path: `/dashboards/${dashboard.id}`, + })), + }, + placement: { position: 'first' }, + }, + ]; + }); +} +``` + +After the plugin changes the state used by the provider, call `refreshMenu` on the backend: + +```ts +await adminforth.resource('dashboards').create({ + name: 'Sales', +}); + +await adminforth.refreshMenu(adminUser); +``` + +AdminForth sends a websocket event to the current user, and the frontend refetches the menu without a page reload. + +Frontend components can also refresh the menu directly: + +```ts +import { useAdminforth } from '@/adminforth'; + +const { menu } = useAdminforth(); + +await menu.refresh(); +``` + +Dynamic menu items should point to routes that are already available in the SPA. If a provider returns a brand-new custom `component` path that was not known during AdminForth build, the menu item can appear, but the route will not be registered until the app is rebuilt. + ## Visibility of menu items You might want to hide some menu items from the menu for some users. diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/06-markdown.md b/adminforth/documentation/docs/tutorial/08-Plugins/06-markdown.md index 3e8eacf35..755d9f0b8 100644 --- a/adminforth/documentation/docs/tutorial/08-Plugins/06-markdown.md +++ b/adminforth/documentation/docs/tutorial/08-Plugins/06-markdown.md @@ -36,12 +36,11 @@ plugins: [ Here is how it looks in the create view: -![alt text](markdown.png) +![alt text](markdown-edit.png) Here is how it looks in show view: -![alt text](markdown-show1.png) -![alt text](markdown-show2.png) +![alt text](markdown-show-1.png) ### Images in Markdown @@ -180,6 +179,8 @@ plugins: [ ### Markdown Top Panel The Markdown plugin also provides a convenient top panel with formatting buttons, allowing users to quickly apply Markdown syntax without typing it manually. +![alt text](markdown-top-panel.png) + By default, the toolbar includes common formatting actions such as: - Bold - Italic diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/markdown-edit.png b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-edit.png new file mode 100644 index 000000000..5cc87e7fc Binary files /dev/null and b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-edit.png differ diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/markdown-show-1.png b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-show-1.png new file mode 100644 index 000000000..0c24f5860 Binary files /dev/null and b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-show-1.png differ diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/markdown-top-panel.png b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-top-panel.png new file mode 100644 index 000000000..2bf565ee9 Binary files /dev/null and b/adminforth/documentation/docs/tutorial/08-Plugins/markdown-top-panel.png differ diff --git a/adminforth/documentation/src/pages/index.tsx b/adminforth/documentation/src/pages/index.tsx index 42043eb36..201206191 100644 --- a/adminforth/documentation/src/pages/index.tsx +++ b/adminforth/documentation/src/pages/index.tsx @@ -135,7 +135,7 @@ const images = [ description: 'Use quick filters to filter your data efficiently. Create custom filters and apply them with a single click' }, { - original: require('@site/static/img/previews/background-jobs.png').default, + original: require('@site/static/img/previews/background-jobs1.png').default, title: 'Background Jobs Plugin - manage your background tasks', link: '/docs/tutorial/Plugins/background-jobs/', description: 'Use background jobs to handle long-running tasks efficiently. Schedule, monitor, and manage your background processes with ease even after server restarts' diff --git a/adminforth/documentation/static/img/previews/background-jobs1.png b/adminforth/documentation/static/img/previews/background-jobs1.png new file mode 100644 index 000000000..d902ba151 Binary files /dev/null and b/adminforth/documentation/static/img/previews/background-jobs1.png differ diff --git a/adminforth/index.ts b/adminforth/index.ts index 11b88c498..1a78495d9 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -31,6 +31,7 @@ import { AdminForthFilterOperators, AdminForthDataTypes, AdminUser, + ActionCheckSource, type AdminForthConfigMenuItem, type AdminForthMenuContribution, type AdminForthMenuTarget, @@ -895,6 +896,85 @@ class AdminForth implements IAdminForth { return { error: null }; } + async runAction({ + resourceId, + actionId, + recordId, + adminUser, + extra = {}, + response, + tr, + }: { + resourceId: string, + actionId: string, + recordId: string | number, + adminUser: AdminUser, + extra, + response?: any, + tr?: any, + }) { + const resource = this.config.resources.find( + (res) => res.resourceId === resourceId + ); + + if (!resource) { + return { + ok: false, + error: `Resource '${resourceId}' not found`, + }; + } + + const action = resource.options.actions?.find( + (act) => act.id === actionId + ); + + if (!action) { + return { + ok: false, + error: `Action '${actionId}' not found`, + }; + } + + if (!action.action) { + return { + ok: false, + error: `Action '${actionId}' has no action handler`, + }; + } + + if (typeof action.allowed === 'function') { + const { allowedActions } = await interpretResource( + adminUser, + resource, + {}, + ActionCheckSource.CustomActionRequest, + this + ); + + const execAllowed = await action.allowed({ + adminUser, + standardAllowedActions: allowedActions, + }); + + if (!execAllowed) { + return { + ok: false, + error: `Action '${actionId}' not allowed`, + }; + } + } + + return await action.action({ + recordId: String(recordId), + adminUser, + resource, + adminforth: this, + response: response as any, + tr: tr as any, + extra, + }); + } + resource(resourceId: string): IOperationalResource { if (this.statuses.dbDiscover !== 'done') { if (this.statuses.dbDiscover === 'running') { diff --git a/adminforth/modules/operationalResource.ts b/adminforth/modules/operationalResource.ts index 50e85c986..fd4da9ad6 100644 --- a/adminforth/modules/operationalResource.ts +++ b/adminforth/modules/operationalResource.ts @@ -64,7 +64,7 @@ export default class OperationalResource implements IOperationalResource { async aggregate( filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule + groupBy?: IGroupByRule | IGroupByRule[] ): Promise> { return this.dataConnector.aggregate({ resource: this.resourceConfig, diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 81c1f58e0..c1eb65229 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1569,18 +1569,17 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const aggregateRequestSchema: AnySchemaObject = { type: 'object', - $defs: commonFilterSchemaDefs, required: ['resourceId', 'aggregations'], properties: { resourceId: { type: 'string' }, aggregations: { type: 'object', - description: 'Map of alias β†’ aggregation rule. Each rule has an "operation" (sum, count, avg, min, max, median) and an optional "field".', + description: 'Map of alias β†’ aggregation rule. Each rule has an "operation" (sum, count, count_distinct, avg, min, max, median) and an optional "field".', additionalProperties: { type: 'object', required: ['operation'], properties: { - operation: { type: 'string', enum: ['sum', 'count', 'avg', 'min', 'max', 'median'] }, + operation: { type: 'string', enum: ['sum', 'count', 'count_distinct', 'avg', 'min', 'max', 'median'] }, field: { type: 'string' }, }, additionalProperties: false, @@ -1588,7 +1587,16 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { }, filters: commonFiltersSchema, groupBy: { - description: 'Optional grouping rule. Either { type: "field", field: "col" } or { type: "date_trunc", field: "col", truncation: "day"|"week"|"month"|"year", timezone?: "IANA/Name" }.', + description: 'Optional grouping rule or array of grouping rules.', + anyOf: [ + { $ref: '#/$defs/aggregateGroupByRule' }, + { type: 'array', items: { $ref: '#/$defs/aggregateGroupByRule' } }, + ], + }, + }, + $defs: { + ...commonFilterSchemaDefs, + aggregateGroupByRule: { anyOf: [ { type: 'object', @@ -1596,6 +1604,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { properties: { type: { type: 'string', enum: ['field'] }, field: { type: 'string' }, + as: { type: 'string' }, }, additionalProperties: false, }, @@ -1607,6 +1616,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { field: { type: 'string' }, truncation: { type: 'string', enum: ['day', 'week', 'month', 'year'] }, timezone: { type: 'string' }, + as: { type: 'string' }, }, additionalProperties: false, }, @@ -1681,9 +1691,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { try { const userTimeZone = headers['X-TimeZone']; - const aggregateGroupBy = groupBy?.type === 'date_trunc' && userTimeZone - ? { ...groupBy, timezone: userTimeZone } - : groupBy; + const applyUserTimeZone = (groupByRule: any) => groupByRule?.type === 'date_trunc' && userTimeZone + ? { ...groupByRule, timezone: userTimeZone } + : groupByRule; + const aggregateGroupBy = Array.isArray(groupBy) + ? groupBy.map(applyUserTimeZone) + : applyUserTimeZone(groupBy); const data = await this.adminforth.connectors[resource.dataSource].aggregate({ resource, diff --git a/adminforth/spa/src/afcl/Button.vue b/adminforth/spa/src/afcl/Button.vue index 4e86bcd87..a7f3a38c2 100644 --- a/adminforth/spa/src/afcl/Button.vue +++ b/adminforth/spa/src/afcl/Button.vue @@ -7,7 +7,7 @@ :class="{ 'cursor-default opacity-50 pointer-events-none': props.disabled, 'active brightness-200 hover:brightness-150' : props.active, - 'text-lightSecondaryContrast/70 bg-lightSecondary border-lightSecondaryContrast/30 dark:bg-darkSecondary hover:bg-lightSecondary/60 hover:border-lightSecondaryContrast/60 focus:ring-lightSecondary dark:focus:ring-darkSecondary/40 dark:text-darkSecondaryContrast dark:border-darkSecondaryContrast/40 dark:hover:bg-darkSecondary/60 dark:hover:border-darkSecondary/60': props.mode === 'secondary', + 'text-lightSecondaryContrast/70 bg-lightSecondary border-lightSecondaryContrast/30 dark:bg-darkSecondary hover:bg-lightSecondary/60 hover:border-lightSecondaryContrast/60 focus:ring-lightSecondary dark:focus:ring-darkSecondary/40 dark:text-darkSecondaryContrast dark:border-darkSecondaryContrast/40 dark:hover:bg-darkSecondary/60 dark:hover:border-white/60': props.mode === 'secondary', }" > diff --git a/adminforth/spa/src/afcl/Dialog.vue b/adminforth/spa/src/afcl/Dialog.vue index 7965830d6..d61303858 100644 --- a/adminforth/spa/src/afcl/Dialog.vue +++ b/adminforth/spa/src/afcl/Dialog.vue @@ -49,6 +49,7 @@ v-bind="button.options" :class="{ 'ms-3': buttonIndex > 0 }" @click="button.onclick(dialog)" + :mode="button.options?.mode || 'primary'" > {{ button.label }} @@ -76,6 +77,56 @@ interface DialogButton { options?: Record } +const modalRef = ref(); + +const props = withDefaults(defineProps(), { + header: '', + headerCloseButton: true, + buttons: () => [], + clickToCloseOutside: false, + closeByEsc: true, + closeByClickOutside: true, + beforeCloseFunction: null, + beforeOpenFunction: null, + closable: false, + askForCloseConfirmation: false, + closeConfirmationText: 'Are you sure you want to close this dialog?', + removeFromDomOnClose: false, +}) + +const buttons = computed(() => { + if (props.buttons && props.buttons.length > 0) { + return props.buttons; + } + return [ + { + label: 'Close', + onclick: (dialog: any) => { + tryToHideModal(); + }, + options: {} + } + ]; +}); + + +function open() { + modalRef.value.open(); +} + +function close() { + modalRef.value.close(); +} + +defineExpose({ + open: open, + close: close, + tryToHideModal: tryToHideModal, +}) + +function tryToHideModal() { + modalRef.value?.tryToHideModal(); +} interface DialogProps { /** @@ -113,7 +164,7 @@ interface DialogProps { /** * Function that will be called before the dialog is closed. */ - beforeCloseFunction?: (() => void | Promise) | null + beforeCloseFunction?: (() => void | Promise) | null /** * Function that will be called before the dialog is opened. @@ -166,58 +217,4 @@ const dialog: Ref = ref( ); /*************************************************************/ - - -const modalRef = ref(); - -const props = withDefaults(defineProps(), { - header: '', - headerCloseButton: true, - buttons: () => [], - clickToCloseOutside: false, - closeByEsc: true, - closeByClickOutside: true, - beforeCloseFunction: null, - beforeOpenFunction: null, - closable: false, - askForCloseConfirmation: false, - closeConfirmationText: 'Are you sure you want to close this dialog?', - removeFromDomOnClose: false, -}) - -const buttons = computed(() => { - if (props.buttons && props.buttons.length > 0) { - return props.buttons; - } - return [ - { - label: 'Close', - onclick: (dialog: any) => { - tryToHideModal(); - }, - options: {} - } - ]; -}); - - -function open() { - modalRef.value.open(); -} - -function close() { - modalRef.value.close(); -} - -defineExpose({ - open: open, - close: close, - tryToHideModal: tryToHideModal, -}) - -function tryToHideModal() { - modalRef.value?.tryToHideModal(); -} - - diff --git a/adminforth/spa/src/afcl/Modal.vue b/adminforth/spa/src/afcl/Modal.vue index 4df4acbda..f41845b64 100644 --- a/adminforth/spa/src/afcl/Modal.vue +++ b/adminforth/spa/src/afcl/Modal.vue @@ -68,7 +68,7 @@ const removeFromDom = computed(() => { interface DialogProps { closeByClickOutside?: boolean closeByEsc?: boolean - beforeCloseFunction?: (() => void | Promise) | null + beforeCloseFunction?: (() => void | Promise) | null beforeOpenFunction?: (() => void | Promise) | null askForCloseConfirmation?: boolean closeConfirmationText?: string @@ -101,7 +101,10 @@ async function open() { async function close() { if (props.beforeCloseFunction) { - await props.beforeCloseFunction?.(); + const shouldClose = await props.beforeCloseFunction?.(); + if (shouldClose === false) { + return; + } } isModalOpen.value = false; } diff --git a/adminforth/spa/src/components/Filters.vue b/adminforth/spa/src/components/Filters.vue index 017fff156..d6ca14c22 100644 --- a/adminforth/spa/src/components/Filters.vue +++ b/adminforth/spa/src/components/Filters.vue @@ -159,7 +159,7 @@ -
diff --git a/adminforth/spa/src/components/ResourceListTable.vue b/adminforth/spa/src/components/ResourceListTable.vue index de8859fea..18fdb04b2 100644 --- a/adminforth/spa/src/components/ResourceListTable.vue +++ b/adminforth/spa/src/components/ResourceListTable.vue @@ -352,8 +352,8 @@ :disableTogleOfSelectedItem="true" :style="{ width: selectDynamicWidth }" :placeholder="pageSizeInternal?.toString()" - class="text-sm min-w-20" - classesForInput="h-[34px] min-h-0 py-1 pl-2 pr-6 text-sm rounded-md cursor-pointer af-button-shadow bg-lightDropdownButtonsBackground text-lightDropdownButtonsText border-lightDropdownButtonsBorder dark:bg-darkDropdownButtonsBackground dark:text-darkDropdownButtonsText dark:border-darkDropdownButtonsBorder" + class="text-sm min-w-20 af-page-size-button" + classesForInput="h-[34px] min-h-0 py-1 pl-2 pr-6 text-sm cursor-pointer af-button-shadow bg-lightDropdownButtonsBackground text-lightDropdownButtonsText border-lightDropdownButtonsBorder dark:bg-darkDropdownButtonsBackground dark:text-darkDropdownButtonsText dark:border-darkDropdownButtonsBorder rounded-default" /> diff --git a/adminforth/spa/src/components/ValueRenderer.vue b/adminforth/spa/src/components/ValueRenderer.vue index 743edd5c2..7c84e0e54 100644 --- a/adminforth/spa/src/components/ValueRenderer.vue +++ b/adminforth/spa/src/components/ValueRenderer.vue @@ -188,6 +188,7 @@ function getArrayItemDisplayValue(value: any, column: AdminForthResourceColumnCo .jv-container .jv-code { padding: 10px 10px; + white-space: pre-wrap; } .jv-container .jv-button[class] { diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 5e4af7546..d1e1034e6 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -402,7 +402,7 @@ export interface IAdminForthDataSourceConnector { resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: IGroupByRule | IGroupByRule[], }): Promise>; } @@ -448,7 +448,7 @@ export interface IAdminForthDataSourceConnectorBase extends IAdminForthDataSourc resource: AdminForthResource, filters: IAdminForthAndOrFilter, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule, + groupBy?: IGroupByRule | IGroupByRule[], }): Promise>; } @@ -1847,8 +1847,8 @@ export interface AdminForthConfig extends Omit IAdminForthSingleFilter; export interface IAggregationRule { - operation: 'sum' | 'count' | 'avg' | 'min' | 'max' | 'median'; - /** Required for sum, avg, min, max, median. Omit for count. */ + operation: 'sum' | 'count' | 'count_distinct' | 'avg' | 'min' | 'max' | 'median'; + /** Required for sum, count_distinct, avg, min, max, median. Omit for count. */ field?: string; } @@ -1858,11 +1858,15 @@ export interface IGroupByDateTrunc { truncation: 'day' | 'week' | 'month' | 'year'; /** IANA timezone name, e.g. 'Europe/Kyiv'. Optional, defaults to UTC. */ timezone?: string; + /** Output key for this grouping. Defaults to "group" for a single groupBy, or group1/group2/... for multiple. */ + as?: string; } export interface IGroupByField { type: 'field'; field: string; + /** Output key for this grouping. Defaults to "group" for a single groupBy, or group1/group2/... for multiple. */ + as?: string; } export type IGroupByRule = IGroupByDateTrunc | IGroupByField; @@ -1873,6 +1877,7 @@ export type IGroupByRule = IGroupByDateTrunc | IGroupByField; export class Aggregates { static sum(field: string): IAggregationRule { return { operation: 'sum', field }; } static count(): IAggregationRule { return { operation: 'count' }; } + static countDistinct(field: string): IAggregationRule { return { operation: 'count_distinct', field }; } static avg(field: string): IAggregationRule { return { operation: 'avg', field }; } static min(field: string): IAggregationRule { return { operation: 'min', field }; } static max(field: string): IAggregationRule { return { operation: 'max', field }; } @@ -1889,16 +1894,16 @@ export class GroupBy { * @param truncation 'day' | 'week' | 'month' | 'year' * @param timezone IANA timezone name, e.g. 'Europe/Kyiv'. Defaults to 'UTC' when omitted. */ - static DateTrunc(field: string, truncation: 'day' | 'week' | 'month' | 'year', timezone?: string): IGroupByDateTrunc { - return { type: 'date_trunc', field, truncation, timezone }; + static DateTrunc(field: string, truncation: 'day' | 'week' | 'month' | 'year', timezone?: string, as?: string): IGroupByDateTrunc { + return { type: 'date_trunc', field, truncation, timezone, as }; } /** * Group by raw field value. The field value is returned as-is in the `group` key. * @param field Column name to group by */ - static Field(field: string): IGroupByField { - return { type: 'field', field }; + static Field(field: string, as?: string): IGroupByField { + return { type: 'field', field, as }; } } @@ -2002,7 +2007,7 @@ export interface IOperationalResource { aggregate: ( filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, aggregations: { [alias: string]: IAggregationRule }, - groupBy?: IGroupByRule + groupBy?: IGroupByRule | IGroupByRule[] ) => Promise>; create: (record: any) => Promise<{ ok: boolean; createdRecord: any; error?: string; }>; diff --git a/dev-demo/resources/cars_resources/carsResourseTemplate.ts b/dev-demo/resources/cars_resources/carsResourseTemplate.ts index 1162a0888..4e2757560 100644 --- a/dev-demo/resources/cars_resources/carsResourseTemplate.ts +++ b/dev-demo/resources/cars_resources/carsResourseTemplate.ts @@ -263,6 +263,20 @@ export default function carsResourseTemplate(resourceId: string, dataSource: Car }), new ForeignInlineListPlugin({ foreignResourceId: 'cars_description_images', + modifyTableResourceConfig(resourceConfig) { + return { + ...resourceConfig, + options: { + ...resourceConfig.options, + allowedActions: { + ...resourceConfig.options?.allowedActions, + create: false, + edit: false, + delete: false, + } + } + } + }, }), new QuickFiltersPlugin({ filters: [