Skip to content

Commit aa94d5f

Browse files
authored
Merge pull request #558 from devforth/next
Next
2 parents 2da0532 + 944780b commit aa94d5f

20 files changed

Lines changed: 328 additions & 66 deletions

File tree

adminforth/commands/createApp/templates/api.ts.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function initApi(app: Express, admin: IAdminForth) {
1111
message: "List of admin users from AdminForth API",
1212
users: allUsers,
1313
});
14-
}
14+
},
1515

1616
// you can use admin.express.authorize to get info about the current user
1717
admin.express.authorize(

adminforth/dataConnectors/clickhouse.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
149149
return dayjs.unix(+value).toISOString();
150150
} else if (field._underlineType.startsWith('DateTime')
151151
|| field._underlineType.startsWith('String')
152-
|| field._underlineType.startsWith('FixedString')) {
152+
|| field._underlineType.startsWith('FixedString')
153+
|| field._underlineType.startsWith('Nullable(String)')
154+
|| field._underlineType.startsWith('Nullable(FixedString)')) {
153155
const v = dayjs(value).toISOString();
154156
return v;
155157
} else {
@@ -163,7 +165,10 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
163165
} else if (field.type == AdminForthDataTypes.BOOLEAN) {
164166
return value === null ? null : !!value;
165167
} else if (field.type == AdminForthDataTypes.JSON) {
166-
if (field._underlineType.startsWith('String') || field._underlineType.startsWith('FixedString')) {
168+
if (field._underlineType.startsWith('String')
169+
|| field._underlineType.startsWith('FixedString')
170+
|| field._underlineType.startsWith('Nullable(String)')
171+
|| field._underlineType.startsWith('Nullable(FixedString)')) {
167172
try {
168173
return JSON.parse(value);
169174
} catch (e) {
@@ -186,7 +191,9 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
186191
return dayjs(value).unix();
187192
} else if (field._underlineType.startsWith('DateTime')
188193
|| field._underlineType.startsWith('String')
189-
|| field._underlineType.startsWith('FixedString')) {
194+
|| field._underlineType.startsWith('FixedString')
195+
|| field._underlineType.startsWith('Nullable(String)')
196+
|| field._underlineType.startsWith('Nullable(FixedString)')) {
190197
// value is iso string now, convert to unix timestamp
191198
const iso = dayjs(value).format('YYYY-MM-DDTHH:mm:ss');
192199
return iso;
@@ -195,7 +202,10 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
195202
return value === null ? null : (value ? 1 : 0);
196203
} else if (field.type == AdminForthDataTypes.JSON) {
197204
// check underline type is text or string
198-
if (field._underlineType.startsWith('String') || field._underlineType.startsWith('FixedString')) {
205+
if (field._underlineType.startsWith('String')
206+
|| field._underlineType.startsWith('FixedString')
207+
|| field._underlineType.startsWith('Nullable(String)')
208+
|| field._underlineType.startsWith('Nullable(FixedString)')) {
199209
return JSON.stringify(value);
200210
} else {
201211
afLogger.warn(`AdminForth: JSON field is not a string/text but ${field._underlineType}, this is not supported yet`);
@@ -226,6 +236,21 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
226236
[AdminForthSortDirections.asc]: 'ASC',
227237
[AdminForthSortDirections.desc]: 'DESC',
228238
};
239+
240+
isArrayType(underlineType: string): boolean {
241+
return underlineType.startsWith('Array(') || underlineType.startsWith('Nullable(Array(');
242+
}
243+
244+
isNullableType(underlineType: string): boolean {
245+
return underlineType.startsWith('Nullable(');
246+
}
247+
248+
isStringLikeType(underlineType: string): boolean {
249+
return underlineType.startsWith('String')
250+
|| underlineType.startsWith('FixedString')
251+
|| underlineType.startsWith('Nullable(String)')
252+
|| underlineType.startsWith('Nullable(FixedString)');
253+
}
229254

230255
getFilterString(resource: AdminForthResource, filter: IAdminForthSingleFilter | IAdminForthAndOrFilter): string {
231256
if ((filter as IAdminForthSingleFilter).field) {
@@ -247,6 +272,49 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
247272
return `${field} ${operator}`;
248273
}
249274

275+
if ((filter.operator == AdminForthFilterOperators.LIKE || filter.operator == AdminForthFilterOperators.ILIKE)
276+
&& column.isArray?.enabled) {
277+
placeholder = '{f$?:String}';
278+
279+
if (this.isArrayType(column._underlineType)) {
280+
const arrayField = this.isNullableType(column._underlineType) ? `assumeNotNull(${field})` : field;
281+
const arrayMatch = `arrayExists(item -> toString(item) ${operator} ${placeholder}, ${arrayField})`;
282+
return this.isNullableType(column._underlineType)
283+
? `${field} IS NOT NULL AND ${arrayMatch}`
284+
: arrayMatch;
285+
}
286+
287+
if (this.isStringLikeType(column._underlineType)) {
288+
return `${field} ${operator} ${placeholder}`;
289+
}
290+
}
291+
292+
if ((filter.operator == AdminForthFilterOperators.IN || filter.operator == AdminForthFilterOperators.NIN)
293+
&& column.isArray?.enabled
294+
&& this.isArrayType(column._underlineType)) {
295+
const itemType = column._underlineType
296+
.replace(/^Nullable\(/, '')
297+
.match(/^Array\((.*)\)$/)?.[1];
298+
299+
if (!itemType) {
300+
throw new Error(`Unable to determine item type for array field '${column.name}' with type '${column._underlineType}'`);
301+
}
302+
303+
placeholder = `{f$?:Array(${itemType})}`;
304+
const arrayField = this.isNullableType(column._underlineType) ? `assumeNotNull(${field})` : field;
305+
const hasAnyExpression = `hasAny(${arrayField}, ${placeholder})`;
306+
307+
if (filter.operator == AdminForthFilterOperators.NIN) {
308+
return this.isNullableType(column._underlineType)
309+
? `(${field} IS NULL OR NOT ${hasAnyExpression})`
310+
: `NOT ${hasAnyExpression}`;
311+
}
312+
313+
return this.isNullableType(column._underlineType)
314+
? `${field} IS NOT NULL AND ${hasAnyExpression}`
315+
: hasAnyExpression;
316+
}
317+
250318
if (column._underlineType.startsWith('Decimal')) {
251319
field = `toDecimal64(${field}, 8)`;
252320
placeholder = `toDecimal64({f$?:String}, 8)`;
@@ -293,20 +361,24 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
293361
}).join(` ${this.OperatorsMap[filter.operator]} `);
294362
}
295363

296-
getFilterParams(filter: IAdminForthSingleFilter | IAdminForthAndOrFilter): any[] {
364+
getFilterParams(resource: AdminForthResource, filter: IAdminForthSingleFilter | IAdminForthAndOrFilter): any[] {
297365
if ((filter as IAdminForthSingleFilter).field) {
298366
if ((filter as IAdminForthSingleFilter).rightField) {
299367
// No params for field-to-field comparisons
300368
return [];
301369
}
302370
// filter is a Single filter
371+
const column = resource.dataSourceColumns.find((col) => col.name == (filter as IAdminForthSingleFilter).field);
303372

304373
// Handle IS_EMPTY and IS_NOT_EMPTY operators - no params needed
305374
if (filter.operator == AdminForthFilterOperators.IS_EMPTY || filter.operator == AdminForthFilterOperators.IS_NOT_EMPTY) {
306375
return [];
307376
} else if (filter.operator == AdminForthFilterOperators.LIKE || filter.operator == AdminForthFilterOperators.ILIKE) {
308377
return [{ 'f': `%${filter.value}%` }];
309378
} else if (filter.operator == AdminForthFilterOperators.IN || filter.operator == AdminForthFilterOperators.NIN) {
379+
if (column?.isArray?.enabled && this.isArrayType(column._underlineType)) {
380+
return [{ 'f': filter.value }];
381+
}
310382
return [{ 'p': filter.value }];
311383
} else if (filter.operator == AdminForthFilterOperators.EQ && filter.value === null) {
312384
// there is no param for IS NULL filter
@@ -326,15 +398,15 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
326398

327399
// filter is a AndOrFilter
328400
return (filter as IAdminForthAndOrFilter).subFilters.reduce((params: any[], f: IAdminForthSingleFilter | IAdminForthAndOrFilter) => {
329-
return params.concat(this.getFilterParams(f));
401+
return params.concat(this.getFilterParams(resource, f));
330402
}, []);
331403
}
332404

333-
whereParams(filters: IAdminForthAndOrFilter): any {
405+
whereParams(resource: AdminForthResource, filters: IAdminForthAndOrFilter): any {
334406
if (filters.subFilters.length === 0) {
335407
return {};
336408
}
337-
const paramsArray = this.getFilterParams(filters);
409+
const paramsArray = this.getFilterParams(resource, filters);
338410
const params = paramsArray.reduce((acc, param, paramIndex) => {
339411
if (param.f !== undefined) {
340412
acc[`f${paramIndex}`] = param.f;
@@ -362,7 +434,7 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
362434
params: {},
363435
}
364436
}
365-
const params = this.whereParams(filters);
437+
const params = this.whereParams(resource, filters);
366438
const where = Object.keys(params).reduce((w, paramKey) => {
367439
// remove first char of string (will be "f" or "p") to leave only index
368440
const keyIndex = paramKey.substring(1);
@@ -388,6 +460,7 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
388460
}).join(', ');
389461
const tableName = resource.table;
390462

463+
console.log('getDataWithOriginalTypes called with filters', JSON.stringify(filters), 'and sort', JSON.stringify(sort));
391464
const { where, params } = this.whereClause(resource, filters);
392465

393466
const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : '';

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ Keep the `<slot />` (that's where AdminForth renders the default button) and emi
224224
<!-- Keep the slot: AdminForth renders the default action button/icon here -->
225225
<!-- Emit `callAction` (optionally with a payload) to trigger the action when the wrapper is clicked -->
226226
<!-- Example: provide `meta.extra` to send custom data. In list views we merge with `row` so recordId context is kept. -->
227-
<div :style="styleObj" @click="emit('callAction', { ...props.row, ...(props.meta?.extra ?? {}) })">
227+
<div :style="styleObj" @click="click({ ...props.row, ...(props.meta?.extra ?? {}) })">
228228
<slot />
229229
</div>
230230
</template>
@@ -248,6 +248,14 @@ const styleObj = computed(() => ({
248248
borderRadius: (props.meta?.radius ?? 8) + 'px',
249249
padding: (props.meta?.padding ?? 2) + 'px',
250250
}));
251+
252+
function click(payload: any) {
253+
emit('callAction', { ...props.row, ...(props.meta?.extra ?? {}) })
254+
}
255+
//we need to define this expose, because padding is added by adminforth wrapper and to trigger click on this padding we use this expose
256+
defineExpose({
257+
click
258+
});
251259
</script>
252260
```
253261

adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,12 @@ Most times you need to refresh the badge from some backend API or hook. To do th
207207
resourceId: 'posts',
208208
//diff-add
209209
itemId: 'postsMenuItem',
210-
badge: async (adminUser: AdminUser) => {
211-
return 10
210+
//diff-add
211+
badge: async (adminUser: AdminUser, adminForth: IAdminForth) => {
212+
//diff-add
213+
const newCount = await adminforth.resource('posts').count(Filters.EQ('verified', false));
214+
//diff-add
215+
return newCount;
212216
},
213217
badgeTooltip: 'Unverified posts', // explain user what this badge means
214218
...
@@ -226,15 +230,13 @@ Most times you need to refresh the badge from some backend API or hook. To do th
226230
table: 'posts',
227231
hooks: {
228232
edit: {
229-
//diff-add
233+
//diff-add
230234
afterSave: async ({ record, adminUser, resource, adminforth }) => {
231-
//diff-add
232-
const newCount = await adminforth.resource('posts').count(Filters.EQ('verified', false));
233-
//diff-add
234-
adminforth.websocket.publish(`/opentopic/update-menu-badge/postsMenuItem`, { badge: newCount });
235-
//diff-add
235+
//diff-add
236+
adminforth.refreshMenuBadge('postsMenuItem', adminUser);
237+
//diff-add
236238
return { ok: true }
237-
//diff-add
239+
//diff-add
238240
}
239241
}
240242
}

adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const users = await admin.resource('adminuser').list(
125125

126126
## Using a raw SQL in queries.
127127

128-
Rarely you might want to add ciondition for some exotic SQL but still want to keep the rest of API.
128+
Rarely you might want to add condition for some exotic SQL but still want to keep the rest of API.
129129
Technically it happened that AdminForth allows you to do this also
130130

131131
```js
@@ -139,9 +139,8 @@ const usersWithNoUgcAccess = await admin.resource('adminuser').list(
139139

140140
], 15, 0, Sorts.DESC('createdAt')
141141
);
142-
143-
This will produce next SQL query:
144142
```
143+
This will produce next SQL query:
145144

146145
```
147146
SELECT *
@@ -150,10 +149,8 @@ WHERE "role" != 'Admin'
150149
AND (user_meta->>'age') < 18
151150
ORDER BY "createdAt" DESC
152151
LIMIT 15 OFFSET 0;
153-
154152
```
155153

156-
157154
Finds users with age less then 18 from meta field which should be a JSONB field in Postgress.
158155

159156
## Create a new item in database

0 commit comments

Comments
 (0)