changable nested elements (#9)

This commit is contained in:
Samuel Colvin
2023-11-27 17:01:56 +00:00
committed by GitHub
parent 41bf68a94c
commit c589e30a61
22 changed files with 421 additions and 188 deletions

View File

@@ -46,6 +46,8 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle
return navbarClassName(subElement)
case 'Link':
return linkClassName(props, fullPath)
case 'LinkList':
return linkListClassName(props, subElement)
}
}
@@ -89,5 +91,22 @@ function navbarClassName(subElement?: string): ClassName {
}
function linkClassName(props: components.LinkProps, fullPath: string): ClassName {
return { active: pathMatch(props.active, fullPath), 'nav-link': props.mode === 'navbar' }
return {
active: pathMatch(props.active, fullPath),
'nav-link': props.mode === 'navbar' || props.mode === 'tabs',
}
}
function linkListClassName(props: components.LinkListProps, subElement?: string): ClassName {
if (subElement === 'link-list-item' && props.mode) {
return 'nav-item'
}
switch (props.mode) {
case 'tabs':
return 'nav nav-underline'
case 'vertical':
return 'nav flex-column'
default:
return ''
}
}

View File

@@ -1,25 +1,27 @@
import { FC } from 'react'
import { components, events, renderClassName } from 'fastui'
import { components, events, renderClassName, EventContextProvider } from 'fastui'
import BootstrapModal from 'react-bootstrap/Modal'
export const Modal: FC<components.ModalProps> = (props) => {
const { className, title, body, footer, openTrigger } = props
const { className, title, body, footer, openTrigger, openContext } = props
const [open, toggle] = events.useEventListenerToggle(openTrigger, props.open)
const { eventContext, clear } = events.usePageEventListen(openTrigger, openContext)
return (
<BootstrapModal className={renderClassName(className)} show={open} onHide={toggle}>
<BootstrapModal.Header closeButton>
<BootstrapModal.Title>{title}</BootstrapModal.Title>
</BootstrapModal.Header>
<BootstrapModal.Body>
<components.AnyCompList propsList={body} />
</BootstrapModal.Body>
{footer && (
<BootstrapModal.Footer className="modal-footer">
<components.AnyCompList propsList={footer} />
</BootstrapModal.Footer>
)}
</BootstrapModal>
<EventContextProvider context={eventContext}>
<BootstrapModal className={renderClassName(className)} show={!!eventContext} onHide={clear}>
<BootstrapModal.Header closeButton>
<BootstrapModal.Title>{title}</BootstrapModal.Title>
</BootstrapModal.Header>
<BootstrapModal.Body>
<components.AnyCompList propsList={body} />
</BootstrapModal.Body>
{footer && (
<BootstrapModal.Footer className="modal-footer">
<components.AnyCompList propsList={footer} />
</BootstrapModal.Footer>
)}
</BootstrapModal>
</EventContextProvider>
)
}

View File

@@ -9,10 +9,15 @@ export interface LinkListProps {
className?: ClassName
}
export const LinkListComp = (props: LinkListProps) => (
<div className={useClassName(props)}>
{props.links.map((link, i) => (
<LinkComp key={i} {...link} />
))}
</div>
)
export const LinkListComp = (props: LinkListProps) => {
const itemClassName = useClassName(props, { el: 'link-list-item' })
return (
<div className={useClassName(props)}>
{props.links.map((link, i) => (
<div key={i} className={itemClassName}>
<LinkComp {...{ ...link, mode: props.mode }} />
</div>
))}
</div>
)
}

View File

@@ -5,7 +5,7 @@ import remarkGfm from 'remark-gfm'
import type { MarkdownProps } from './Markdown'
import { useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { useFireEvent, AnyEvent } from '../events'
import { useCustomRender } from '../hooks/config'
import { CodeProps, CodeComp } from './Code'

View File

@@ -1,42 +1,69 @@
import { FC, useContext, useEffect, useState } from 'react'
import { ErrorContext } from '../hooks/error'
import { ReloadContext } from '../hooks/dev'
import { useRequest } from '../tools'
import { DefaultLoading } from '../DefaultLoading'
import { ConfigContext } from '../hooks/config'
import { PageEvent, usePageEventListen } from '../events'
import { EventContextProvider, useEventContext } from '../hooks/eventContext'
import { AnyCompList, FastProps } from './index'
export interface ServerLoadProps {
type: 'ServerLoad'
url: string
path: string
components?: FastProps[]
loadTrigger?: PageEvent
}
export const ServerLoadComp: FC<ServerLoadProps> = ({ path, components, loadTrigger }) => {
if (components) {
return <ServerLoadDefer path={path} components={components} loadTrigger={loadTrigger} />
} else {
return <ServerLoadDirect path={path} />
}
}
export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
const ServerLoadDefer: FC<{ path: string; components: FastProps[]; loadTrigger?: PageEvent }> = ({
components,
path,
loadTrigger,
}) => {
const { eventContext } = usePageEventListen(loadTrigger)
if (eventContext) {
return (
<EventContextProvider context={eventContext}>
<ServerLoadDirect path={path} />
</EventContextProvider>
)
} else {
return <AnyCompList propsList={components} />
}
}
export const ServerLoadDirect: FC<{ path: string; devReload?: number }> = ({ path, devReload }) => {
const [componentProps, setComponentProps] = useState<FastProps[] | null>(null)
const { error, setError } = useContext(ErrorContext)
const reloadValue = useContext(ReloadContext)
const { error } = useContext(ErrorContext)
const { rootUrl, pathSendMode, Loading } = useContext(ConfigContext)
const request = useRequest()
const applyContext = useEventContext()
useEffect(() => {
let fetchUrl = rootUrl
const requestPath = applyContext(path)
if (pathSendMode === 'query') {
fetchUrl += `?path=${encodeURIComponent(url)}`
fetchUrl += `?path=${encodeURIComponent(requestPath)}`
} else {
fetchUrl += url
fetchUrl += requestPath
}
const promise = request({ url: fetchUrl })
promise.then(([, data]) => setComponentProps(data as FastProps[]))
return () => {
promise.then(() => null)
}
}, [rootUrl, pathSendMode, url, setError, reloadValue, request])
}, [rootUrl, pathSendMode, path, applyContext, request, devReload])
if (componentProps === null) {
if (error) {

View File

@@ -1,7 +1,7 @@
import { FC } from 'react'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { useFireEvent, AnyEvent } from '../events'
export interface ButtonProps {
type: 'Button'

View File

@@ -1,7 +1,7 @@
import { FC, FormEvent, useState } from 'react'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { useFireEvent, AnyEvent } from '../events'
import { useRequest } from '../tools'
import { FastProps, AnyCompList } from './index'

View File

@@ -1,7 +1,7 @@
import { FC, MouseEventHandler, ReactNode } from 'react'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { useFireEvent, AnyEvent } from '../events'
import { FastProps, AnyCompList } from './index'

View File

@@ -1,9 +1,10 @@
import { FC, useEffect } from 'react'
import { ClassName } from '../hooks/className'
import { PageEvent, useEventListenerToggle } from '../hooks/events'
import type { FastProps } from './index'
import type { ContextType } from '../hooks/eventContext'
import { FastProps } from './index'
import { ClassName } from '../hooks/className'
import { PageEvent, usePageEventListen } from '../events'
export interface ModalProps {
type: 'Modal'
@@ -11,23 +12,24 @@ export interface ModalProps {
body: FastProps[]
footer?: FastProps[]
openTrigger?: PageEvent
open?: boolean
openContext?: ContextType
className?: ClassName
}
export const ModalComp: FC<ModalProps> = (props) => {
const { title, openTrigger } = props
const { title, openTrigger, openContext } = props
const [open, toggle] = useEventListenerToggle(openTrigger, props.open)
const { eventContext, clear } = usePageEventListen(openTrigger, openContext)
const open = !!eventContext
useEffect(() => {
if (open) {
setTimeout(() => {
alert(`${title}\n\nNote: modals are not implemented by pure FastUI, implement a component for 'ModalProps'.`)
toggle()
clear()
})
}
}, [open, title, toggle])
}, [open, title, clear])
return <></>
}

View File

@@ -1,5 +1,5 @@
import { ClassName, useClassName } from '../hooks/className'
import { AnyEvent } from '../hooks/events'
import { AnyEvent } from '../events'
import { LinkProps, LinkComp, LinkRender } from './link'

View File

@@ -4,7 +4,7 @@ import type { JsonData } from './Json'
import { DisplayChoices, asTitle } from '../display'
import { ClassName, useClassName } from '../hooks/className'
import { AnyEvent } from '../hooks/events'
import { AnyEvent } from '../events'
import { DisplayComp } from './display'
import { LinkRender } from './link'

View File

@@ -1,10 +1,28 @@
import { useContext } from 'react'
import { useContext, useEffect, useState } from 'react'
import { LocationContext } from './hooks/locationContext'
import { ServerLoadComp } from './components/ServerLoad'
import { ServerLoadDirect } from './components/ServerLoad'
import { loadEvent, LoadEventDetail } from './events'
export function FastUIController() {
const { fullPath } = useContext(LocationContext)
const [path, setPath] = useState(fullPath)
const [reloadValue, setReloadValue] = useState(0)
return <ServerLoadComp type="ServerLoad" url={fullPath} />
useEffect(() => {
function onEvent(e: Event) {
const { path, reloadValue } = (e as CustomEvent<LoadEventDetail>).detail
setPath(path ?? fullPath)
setReloadValue(reloadValue ?? 0)
}
document.addEventListener(loadEvent, onEvent)
return () => {
document.removeEventListener(loadEvent, onEvent)
}
}, [fullPath])
return <ServerLoadDirect path={path} devReload={reloadValue} />
}

View File

@@ -1,19 +1,30 @@
import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react'
import { FC, useContext, useEffect } from 'react'
import { ErrorContext } from './error'
import { sleep } from './tools'
import { ErrorContext } from './hooks/error'
import { fireLoadEvent } from './events'
export const ReloadContext = createContext<number>(0)
let devConnected = false
export const DevReloadProvider: FC<{ children: ReactNode; enabled?: boolean }> = ({ children, enabled }) => {
const [value, setValue] = useState<number>(0)
const { setError } = useContext(ErrorContext)
export const DevReload: FC<{ enabled?: boolean }> = ({ enabled }) => {
if (typeof enabled === 'undefined') {
enabled = process.env.NODE_ENV === 'development'
}
if (enabled) {
return <DevReloadActive />
} else {
return <></>
}
}
const DevReloadActive = () => {
const { setError } = useContext(ErrorContext)
useEffect(() => {
let listening = true
let lastValue = 0
async function listen() {
let count = 0
let failCount = 0
@@ -32,17 +43,20 @@ export const DevReloadProvider: FC<{ children: ReactNode; enabled?: boolean }> =
}
// await like this means we wait for the entire response to be received
const text = await response.text()
const value = parseInt(text.replace(/\./g, '')) || 0
if (response.status === 404) {
console.log('dev reload endpoint not found, disabling dev reload')
return count
} else if (response.ok) {
failCount = 0
// wait long enough for the server to be back online
await sleep(300)
console.debug('dev reloading')
setValue(value)
setError(null)
const value = parseInt(text.replace(/\./g, '')) || 0
if (value !== lastValue) {
lastValue = value
// wait long enough for the server to be back online
await sleep(300)
console.debug('dev reloading')
fireLoadEvent({ reloadValue: value })
setError(null)
}
} else {
failCount++
await sleep(2000)
@@ -50,7 +64,7 @@ export const DevReloadProvider: FC<{ children: ReactNode; enabled?: boolean }> =
}
}
if (enabled && !devConnected) {
if (!devConnected) {
devConnected = true
listen().then((count) => count > 0 && console.debug('dev reload disconnected.'))
return () => {
@@ -58,11 +72,6 @@ export const DevReloadProvider: FC<{ children: ReactNode; enabled?: boolean }> =
devConnected = false
}
}
}, [enabled, setError])
return <ReloadContext.Provider value={value}>{children}</ReloadContext.Provider>
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}, [setError])
return <></>
}

View File

@@ -0,0 +1,105 @@
import { useContext, useState, useEffect, useCallback } from 'react'
import { LocationContext } from './hooks/locationContext'
import { ContextType } from './hooks/eventContext'
export interface PageEvent {
type: 'page'
name: string
pushPath?: string
context?: ContextType
clear?: boolean
}
export interface GoToEvent {
type: 'go-to'
url: string
}
export interface BackEvent {
type: 'back'
}
export type AnyEvent = PageEvent | GoToEvent | BackEvent
export interface PageEventDetail {
clear: boolean
context?: ContextType
}
function pageEventType(event: PageEvent): string {
return `fastui:${event.name}`
}
export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } {
const location = useContext(LocationContext)
function fireEvent(event?: AnyEvent) {
if (!event) {
return
}
console.debug('firing event', event)
const { type } = event
switch (type) {
case 'page': {
if (event.pushPath) {
location.gotoCosmetic(event.pushPath)
}
const detail: PageEventDetail = { clear: event.clear || false, context: event.context }
document.dispatchEvent(new CustomEvent(pageEventType(event), { detail }))
break
}
case 'go-to':
location.goto(event.url)
break
case 'back':
location.back()
break
}
}
return { fireEvent }
}
export const loadEvent = 'fastui:load'
export interface LoadEventDetail {
path?: string
reloadValue?: number
}
export function fireLoadEvent(detail: LoadEventDetail) {
document.dispatchEvent(new CustomEvent(loadEvent, { detail }))
}
export function usePageEventListen(
event?: PageEvent,
initialContext: ContextType | null = null,
): { eventContext: ContextType | null; clear: () => void } {
const [eventContext, setEventContext] = useState<ContextType | null>(initialContext)
const onEvent = useCallback((e: Event) => {
const { context, clear } = (e as CustomEvent<PageEventDetail>).detail
if (clear) {
setEventContext(null)
} else {
setEventContext(context ?? {})
}
}, [])
useEffect(() => {
if (!event) {
return
}
const eventType = pageEventType(event)
document.addEventListener(eventType, onEvent)
return () => document.removeEventListener(eventType, onEvent)
}, [event, onEvent])
return {
eventContext,
clear: useCallback(() => setEventContext(null), []),
}
}

View File

@@ -0,0 +1,33 @@
import { createContext, FC, ReactNode, useCallback, useContext } from 'react'
export type ContextType = Record<string, string | number>
const EventContext = createContext<ContextType | null>(null)
export const useEventContext = (): ((template: string) => string) => {
const context = useContext(EventContext)
return useCallback((template: string): string => applyContext(template, context), [context])
}
export const EventContextProvider: FC<{ children: ReactNode; context: ContextType | null }> = ({
children,
context,
}) => {
return <EventContext.Provider value={context}>{children}</EventContext.Provider>
}
const applyContext = (template: string, context: ContextType | null): string => {
if (!context) {
return template
}
return template.replace(/{(.+?)}/g, (_, key: string): string => {
const v = context[key]
if (v === undefined) {
throw new Error(`field "${key}" not found in ${JSON.stringify(context)}`)
} else {
return v.toString()
}
})
}

View File

@@ -1,67 +0,0 @@
import { useContext, useState, useEffect, useCallback } from 'react'
import { LocationContext } from './locationContext'
export interface PageEvent {
type: 'page'
name: string
}
export interface GoToEvent {
type: 'go-to'
url: string
}
export interface BackEvent {
type: 'back'
}
export type AnyEvent = PageEvent | GoToEvent | BackEvent
function pageEventType(event: PageEvent): string {
return `fastui:${event.name}`
}
export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } {
const location = useContext(LocationContext)
function fireEvent(event?: AnyEvent) {
if (!event) {
return
}
console.debug('firing event', event)
const { type } = event
switch (type) {
case 'page':
document.dispatchEvent(new CustomEvent(pageEventType(event)))
break
case 'go-to':
location.goto(event.url)
break
case 'back':
location.back()
break
}
}
return { fireEvent }
}
export function useEventListenerToggle(event?: PageEvent, initialState = false): [boolean, () => void] {
const [state, setState] = useState(initialState)
const toggle = useCallback(() => setState((state) => !state), [])
useEffect(() => {
if (!event) {
return
}
const eventType = pageEventType(event)
document.addEventListener(eventType, toggle)
return () => document.removeEventListener(eventType, toggle)
}, [event, toggle])
return [state, toggle]
}

View File

@@ -1,5 +1,7 @@
import { createContext, ReactNode, useEffect, useState, useCallback, useContext } from 'react'
import { fireLoadEvent } from '../events'
import { ErrorContext } from './error'
function parseLocation(): string {
@@ -11,6 +13,8 @@ function parseLocation(): string {
export interface LocationState {
fullPath: string
goto: (pushPath: string) => void
// like `goto`, but does not fire `fireLoadEvent`
gotoCosmetic: (pushPath: string) => void
back: () => void
}
@@ -19,6 +23,7 @@ const initialPath = parseLocation()
const initialState = {
fullPath: initialPath,
goto: () => null,
gotoCosmetic: () => null,
back: () => null,
}
@@ -32,6 +37,7 @@ export function LocationProvider({ children }: { children: ReactNode }) {
const fullPath = parseLocation()
setError(null)
setFullPath(fullPath)
fireLoadEvent({ path: fullPath })
}, [setError, setFullPath])
useEffect(() => {
@@ -41,34 +47,48 @@ export function LocationProvider({ children }: { children: ReactNode }) {
}
}, [onPopState])
const value: LocationState = {
fullPath,
goto: useCallback(
(pushPath: string) => {
let newPath = pushPath
if (!newPath.startsWith('/')) {
// get rid of `.` and `./` at the beginning of the path
if (newPath.startsWith('.')) {
const pushPath = useCallback(
(newPath: string): string => {
if (!newPath.startsWith('/')) {
// get rid of `.` and `./` at the beginning of the path
if (newPath.startsWith('.')) {
newPath = newPath.slice(1)
if (newPath.startsWith('/')) {
newPath = newPath.slice(1)
if (newPath.startsWith('/')) {
newPath = newPath.slice(1)
}
}
const oldPath = new URL(window.location.href).pathname
// we're now sure newPath does not start with a `/`
if (oldPath.endsWith('/')) {
newPath = oldPath + newPath
} else {
newPath = oldPath + '/' + newPath
}
}
window.history.pushState(null, '', newPath)
setError(null)
setFullPath(newPath)
const oldPath = new URL(window.location.href).pathname
// we're now sure newPath does not start with a `/`
if (oldPath.endsWith('/')) {
newPath = oldPath + newPath
} else {
newPath = oldPath + '/' + newPath
}
}
window.history.pushState(null, '', newPath)
setError(null)
setFullPath(newPath)
return newPath
},
[setError],
)
const value: LocationState = {
fullPath,
goto: useCallback(
(newPath: string) => {
const path = pushPath(newPath)
fireLoadEvent({ path })
},
[setError],
[pushPath],
),
gotoCosmetic: useCallback(
(newPath: string) => {
pushPath(newPath)
},
[pushPath],
),
back: useCallback(() => {
window.history.back()
@@ -84,7 +104,7 @@ export function pathMatch(matchPath: string | boolean | undefined, fullPath: str
const regex = new RegExp(matchPath.slice(6))
return regex.test(fullPath)
} else if (matchPath.startsWith('startswith:')) {
return fullPath.startsWith(matchPath.slice(12))
return fullPath.startsWith(matchPath.slice(11))
} else {
return fullPath === matchPath
}

View File

@@ -8,14 +8,14 @@ import { ClassNameContext, ClassNameGenerator } from './hooks/className'
import { ErrorContextProvider } from './hooks/error'
import { ConfigContext } from './hooks/config'
import { FastProps } from './components'
import { DevReloadProvider } from './hooks/dev'
import { DevReload } from './dev'
export * as components from './components'
export * as events from './hooks/events'
export * as events from './events'
export type { DisplayChoices } from './display'
export type { ClassName, ClassNameGenerator } from './hooks/className'
export { useClassName, renderClassName } from './hooks/className'
export { pathMatch } from './hooks/locationContext'
export { EventContextProvider } from './hooks/eventContext'
export type CustomRender = (props: FastProps) => FC | void
@@ -36,15 +36,14 @@ export function FastUI(props: FastUIProps) {
return (
<div className="fastui">
<ErrorContextProvider DisplayError={DisplayError}>
<DevReloadProvider enabled={devMode}>
<LocationProvider>
<ClassNameContext.Provider value={classNameGenerator ?? null}>
<ConfigContext.Provider value={rest}>
<FastUIController />
</ConfigContext.Provider>
</ClassNameContext.Provider>
</LocationProvider>
</DevReloadProvider>
<LocationProvider>
<ClassNameContext.Provider value={classNameGenerator ?? null}>
<ConfigContext.Provider value={rest}>
<DevReload enabled={devMode} />
<FastUIController />
</ConfigContext.Provider>
</ClassNameContext.Provider>
</LocationProvider>
</ErrorContextProvider>
</div>
)

View File

@@ -138,3 +138,7 @@ export function debounce<C extends Callable>(fn: C, delay: number): C {
timerId = setTimeout(() => fn(...args), delay)
}
}
export async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -10,7 +10,7 @@ from fastapi import UploadFile
from fastui import AnyComponent, FastUI, dev_fastapi_app
from fastui import components as c
from fastui.display import Display
from fastui.events import BackEvent, GoToEvent, PageEvent
from fastui.events import GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
from httpx import AsyncClient
from pydantic import BaseModel, Field, SecretStr, field_validator
@@ -26,7 +26,7 @@ def navbar() -> AnyComponent:
links=[
c.Link(components=[c.Text(text='Home')], on_click=GoToEvent(url='/'), active='/'),
c.Link(components=[c.Text(text='Table')], on_click=GoToEvent(url='/table'), active='/table'),
c.Link(components=[c.Text(text='Forms')], on_click=GoToEvent(url='/form'), active='/form'),
c.Link(components=[c.Text(text='Forms')], on_click=GoToEvent(url='/form/one'), active='startswith:/form'),
],
)
@@ -84,15 +84,15 @@ assert x + y == 3
title='Static Modal',
body=[c.Paragraph(text='This is some static content in a modal.')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='static-modal')),
c.Button(text='Close', on_click=PageEvent(name='static-modal', clear=True)),
],
open_trigger=PageEvent(name='static-modal'),
),
c.Modal(
title='Dynamic Modal',
body=[c.ServerLoad(url='/modal')],
body=[c.ServerLoad(path='/modal')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='dynamic-modal')),
c.Button(text='Close', on_click=PageEvent(name='dynamic-modal', clear=True)),
],
open_trigger=PageEvent(name='dynamic-modal'),
),
@@ -200,15 +200,50 @@ async def search_view(q: str) -> SelectSearchResponse:
return SelectSearchResponse(options=options)
@app.get('/api/form', response_model=FastUI, response_model_exclude_none=True)
def form_view() -> list[AnyComponent]:
@app.get('/api/form/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]:
return [
navbar(),
c.PageTitle(text='FastUI Demo - Form Examples'),
c.Page(
components=[
c.Heading(text='Form'),
c.Link(components=[c.Text(text='Back')], on_click=BackEvent()),
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Form One')],
on_click=PageEvent(name='change-form', push_path='/form/one', context={'kind': 'one'}),
active='/form/one',
),
c.Link(
components=[c.Text(text='Form Two')],
on_click=PageEvent(name='change-form', push_path='/form/two', context={'kind': 'two'}),
active='/form/two',
),
c.Link(
components=[c.Text(text='Form Three')],
on_click=PageEvent(name='change-form', push_path='/form/three', context={'kind': 'three'}),
active='/form/three',
),
],
mode='tabs',
),
c.ServerLoad(
path='/form/content/{kind}',
load_trigger=PageEvent(name='change-form'),
components=form_content(kind),
),
]
),
]
@app.get('/api/form/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_content(kind: Literal['one', 'two', 'three']):
match kind:
case 'one':
return [
c.Heading(text='Form One', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
@@ -218,8 +253,24 @@ def form_view() -> list[AnyComponent]:
# ]
),
]
),
]
case 'two':
return [
c.Heading(text='Form Two', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
),
]
case 'three':
return [
c.Heading(text='Form Three', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
),
]
case _:
raise ValueError(f'Invalid kind {kind!r}')
@app.post('/api/form')

View File

@@ -136,7 +136,7 @@ class Modal(pydantic.BaseModel, extra='forbid'):
body: list[AnyComponent]
footer: list[AnyComponent] | None = None
open_trigger: events.PageEvent | None = pydantic.Field(default=None, serialization_alias='openTrigger')
open: bool = False
open_context: events.EventContext | None = pydantic.Field(default=None, serialization_alias='openContext')
class_name: extra.ClassName = None
type: typing.Literal['Modal'] = 'Modal'
@@ -146,8 +146,9 @@ class ServerLoad(pydantic.BaseModel, extra='forbid'):
A component that will be replaced by the server with the component returned by the given URL.
"""
url: str
class_name: extra.ClassName = None
path: str
load_trigger: events.PageEvent | None = pydantic.Field(default=None, serialization_alias='loadTrigger')
components: list[AnyComponent] | None = None
type: typing.Literal['ServerLoad'] = 'ServerLoad'

View File

@@ -1,10 +1,15 @@
from typing import Annotated, Literal
from typing import Annotated, Literal, TypeAlias
from pydantic import BaseModel, Field
EventContext: TypeAlias = dict[str, str | int]
class PageEvent(BaseModel):
name: str
push_path: str | None = Field(default=None, serialization_alias='pushPath')
context: EventContext | None = None
clear: bool | None = None
type: Literal['page'] = 'page'