plugins: support async for html-producing frontend events
Massimo Melina committed
Mar 22, 2025 at 19:41 UTC
e03f0e1ec657cbf8ae48eaaa55d88758773ab01d
2 files changed
+30
-17
dev-plugins.md
+1
@@ -400,6 +400,7 @@ Some frontend events can return HTML, which can be expressed in several ways:
400
- as a ReactElement
401
- as an array of ReactNode
402
- `null`, `undefined`, `false`, and empty strings will be discarded
403
+- as a Promise for any of the above
404
405
These events will receive a `def` property (in addition event's specific properties),
406
with the default content that will be displayed if no callback return a valid output.
frontend/src/components.ts
+29
-17
@@ -1,12 +1,12 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import {
4
- Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix, noAriaTitle, formatBytes
4
+ Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix, noAriaTitle, formatBytes, useStateMounted
5
} from './misc'
6
import {
7
ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment,
8
HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes,
9
- useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes, useRef, ReactElement
9
+ useEffect, useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes, useRef, ReactElement
10
} from 'react'
11
import _ from 'lodash'
12
import i18n from './i18n'
@@ -83,24 +83,36 @@ export function CustomCode({ name, children, render, ...props }: {
83
render?: Callback<ReactNode, ReactNode>
84
[k :string]: any,
85
}) {
86
- const result = useMemo(() => {
87
- props.def = children // not using 'default' because user can have unexpected error destructuring object
88
- let keep = true
89
- const ret = onlyTruthy(hfsEvent(name, props)
90
- .map((x, key) => isValidElement(x) ? h(Fragment, { key }, x)
86
+ const raw = useMemo(() => hfsEvent(name, Object.assign(props, { def: children })), // not using 'default' as key, because user can have unexpected error destructuring object
87
+ [name, children, ...props ? Object.values(props) : []])
88
+ const [out, setOut] = useStateMounted<null | ReactNode[]>([])
89
+ useEffect(() => {
90
+ if (raw.some(x => x === null)) // null means skip this
91
+ return setOut(null)
92
+ const worked: ReactNode[] = raw.map(toElement)
93
+ setOut(onlyTruthy(worked))
94
+ raw.forEach((x, i) => {
95
+ if (typeof x?.then === 'function') // thenable
96
+ x.then((resolved: any) => {
97
+ worked[i] = toElement(resolved, i)
98
+ setOut(onlyTruthy(worked))
99
+ }, () => {})
100
+ })
101
+ const html = getHFS().customHtml?.[name]
102
+ if (html?.trim?.()) {
103
+ worked.push(toElement(html, -1))
104
+ setOut(onlyTruthy(worked))
105
+ }
106
+
107
+ function toElement(x: unknown, key: number) {
108
+ return isValidElement(x) ? h(Fragment, { key }, x) // wrap to avoid console warnings
109
: x === 0 || x && isPrimitive(x) ? h(Html, { key }, String(x))
110
: _.isArray(x) ? h(Fragment, { key }, ...x)
93
- : x === null ? keep = false
94
- : null))
95
- if (!keep)
96
- return null
97
- const html = getHFS().customHtml?.[name]
98
- if (html?.trim?.())
99
- ret.push(h(Html, { key: 'x' }, html))
100
- return ret
101
- }, [name, children, ...props ? Object.values(props) : []])
111
+ : null
112
+ }
113
+ }, [raw])
114
render ??= _.identity
103
- return h(Fragment, {}, render(result && (result.length || !children ? result : children)) )
115
+ return h(Fragment, {}, render(out && (out?.length || !children ? out : children)) )
116
}
117
118
interface IconBtnOptions extends ButtonHTMLAttributes<any> { style?: any, title?: string }