diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx index e57a93bf..d998439f 100644 --- a/packages/fastui-bootstrap/src/index.tsx +++ b/packages/fastui-bootstrap/src/index.tsx @@ -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 '' + } } diff --git a/packages/fastui-bootstrap/src/modal.tsx b/packages/fastui-bootstrap/src/modal.tsx index 79f13a18..2d8d73c5 100644 --- a/packages/fastui-bootstrap/src/modal.tsx +++ b/packages/fastui-bootstrap/src/modal.tsx @@ -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 = (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 ( - - - {title} - - - - - {footer && ( - - - - )} - + + + + {title} + + + + + {footer && ( + + + + )} + + ) } diff --git a/packages/fastui/src/components/LinkList.tsx b/packages/fastui/src/components/LinkList.tsx index 91cc12ff..0baea4fc 100644 --- a/packages/fastui/src/components/LinkList.tsx +++ b/packages/fastui/src/components/LinkList.tsx @@ -9,10 +9,15 @@ export interface LinkListProps { className?: ClassName } -export const LinkListComp = (props: LinkListProps) => ( -
- {props.links.map((link, i) => ( - - ))} -
-) +export const LinkListComp = (props: LinkListProps) => { + const itemClassName = useClassName(props, { el: 'link-list-item' }) + return ( +
+ {props.links.map((link, i) => ( +
+ +
+ ))} +
+ ) +} diff --git a/packages/fastui/src/components/MarkdownLazy.tsx b/packages/fastui/src/components/MarkdownLazy.tsx index 110d172e..8ef0a823 100644 --- a/packages/fastui/src/components/MarkdownLazy.tsx +++ b/packages/fastui/src/components/MarkdownLazy.tsx @@ -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' diff --git a/packages/fastui/src/components/ServerLoad.tsx b/packages/fastui/src/components/ServerLoad.tsx index 5564bb36..0f5a31da 100644 --- a/packages/fastui/src/components/ServerLoad.tsx +++ b/packages/fastui/src/components/ServerLoad.tsx @@ -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 = ({ path, components, loadTrigger }) => { + if (components) { + return + } else { + return + } +} + +const ServerLoadDefer: FC<{ path: string; components: FastProps[]; loadTrigger?: PageEvent }> = ({ + components, + path, + loadTrigger, +}) => { + const { eventContext } = usePageEventListen(loadTrigger) + + if (eventContext) { + return ( + + + + ) + } else { + return + } } -export const ServerLoadComp: FC = ({ url }) => { +export const ServerLoadDirect: FC<{ path: string; devReload?: number }> = ({ path, devReload }) => { const [componentProps, setComponentProps] = useState(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) { diff --git a/packages/fastui/src/components/button.tsx b/packages/fastui/src/components/button.tsx index 31ed1361..fc73bbec 100644 --- a/packages/fastui/src/components/button.tsx +++ b/packages/fastui/src/components/button.tsx @@ -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' diff --git a/packages/fastui/src/components/form.tsx b/packages/fastui/src/components/form.tsx index 25ae6f48..f268b31f 100644 --- a/packages/fastui/src/components/form.tsx +++ b/packages/fastui/src/components/form.tsx @@ -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' diff --git a/packages/fastui/src/components/link.tsx b/packages/fastui/src/components/link.tsx index ddc8fc0e..091d0419 100644 --- a/packages/fastui/src/components/link.tsx +++ b/packages/fastui/src/components/link.tsx @@ -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' diff --git a/packages/fastui/src/components/modal.tsx b/packages/fastui/src/components/modal.tsx index d4459fb8..3d804a4c 100644 --- a/packages/fastui/src/components/modal.tsx +++ b/packages/fastui/src/components/modal.tsx @@ -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 = (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 <> } diff --git a/packages/fastui/src/components/navbar.tsx b/packages/fastui/src/components/navbar.tsx index 1c1e9d13..da7054eb 100644 --- a/packages/fastui/src/components/navbar.tsx +++ b/packages/fastui/src/components/navbar.tsx @@ -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' diff --git a/packages/fastui/src/components/table.tsx b/packages/fastui/src/components/table.tsx index 3c0c4f1a..5c74ff99 100644 --- a/packages/fastui/src/components/table.tsx +++ b/packages/fastui/src/components/table.tsx @@ -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' diff --git a/packages/fastui/src/controller.tsx b/packages/fastui/src/controller.tsx index 098b79a8..461c8720 100644 --- a/packages/fastui/src/controller.tsx +++ b/packages/fastui/src/controller.tsx @@ -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 + useEffect(() => { + function onEvent(e: Event) { + const { path, reloadValue } = (e as CustomEvent).detail + + setPath(path ?? fullPath) + setReloadValue(reloadValue ?? 0) + } + + document.addEventListener(loadEvent, onEvent) + + return () => { + document.removeEventListener(loadEvent, onEvent) + } + }, [fullPath]) + + return } diff --git a/packages/fastui/src/hooks/dev.tsx b/packages/fastui/src/dev.tsx similarity index 62% rename from packages/fastui/src/hooks/dev.tsx rename to packages/fastui/src/dev.tsx index 9fe75ae5..4c6e3d52 100644 --- a/packages/fastui/src/hooks/dev.tsx +++ b/packages/fastui/src/dev.tsx @@ -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(0) let devConnected = false -export const DevReloadProvider: FC<{ children: ReactNode; enabled?: boolean }> = ({ children, enabled }) => { - const [value, setValue] = useState(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 + } 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 {children} -} - -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) + }, [setError]) + return <> } diff --git a/packages/fastui/src/events.ts b/packages/fastui/src/events.ts new file mode 100644 index 00000000..13409f74 --- /dev/null +++ b/packages/fastui/src/events.ts @@ -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(initialContext) + + const onEvent = useCallback((e: Event) => { + const { context, clear } = (e as CustomEvent).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), []), + } +} diff --git a/packages/fastui/src/hooks/eventContext.tsx b/packages/fastui/src/hooks/eventContext.tsx new file mode 100644 index 00000000..65ca50ba --- /dev/null +++ b/packages/fastui/src/hooks/eventContext.tsx @@ -0,0 +1,33 @@ +import { createContext, FC, ReactNode, useCallback, useContext } from 'react' + +export type ContextType = Record + +const EventContext = createContext(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 {children} +} + +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() + } + }) +} diff --git a/packages/fastui/src/hooks/events.ts b/packages/fastui/src/hooks/events.ts deleted file mode 100644 index 95fc6202..00000000 --- a/packages/fastui/src/hooks/events.ts +++ /dev/null @@ -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] -} diff --git a/packages/fastui/src/hooks/locationContext.tsx b/packages/fastui/src/hooks/locationContext.tsx index cb06c465..6c49d245 100644 --- a/packages/fastui/src/hooks/locationContext.tsx +++ b/packages/fastui/src/hooks/locationContext.tsx @@ -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 - } + 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) + 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 }) + }, + [pushPath], + ), + gotoCosmetic: useCallback( + (newPath: string) => { + pushPath(newPath) }, - [setError], + [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 } diff --git a/packages/fastui/src/index.tsx b/packages/fastui/src/index.tsx index 6e2de5fb..23218fa1 100644 --- a/packages/fastui/src/index.tsx +++ b/packages/fastui/src/index.tsx @@ -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 (
- - - - - - - - - + + + + + + + +
) diff --git a/packages/fastui/src/tools.ts b/packages/fastui/src/tools.ts index 201952d7..32eac513 100644 --- a/packages/fastui/src/tools.ts +++ b/packages/fastui/src/tools.ts @@ -138,3 +138,7 @@ export function debounce(fn: C, delay: number): C { timerId = setTimeout(() => fn(...args), delay) } } + +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/python/demo/main.py b/python/demo/main.py index 0b3a29d3..0468efad 100644 --- a/python/demo/main.py +++ b/python/demo/main.py @@ -10,7 +10,7 @@ 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 @@ def read_root() -> list[AnyComponent]: 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') diff --git a/python/fastui/components/__init__.py b/python/fastui/components/__init__.py index ec530c02..c0db39fb 100644 --- a/python/fastui/components/__init__.py +++ b/python/fastui/components/__init__.py @@ -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' diff --git a/python/fastui/events.py b/python/fastui/events.py index 8cf424a3..50fcbf3d 100644 --- a/python/fastui/events.py +++ b/python/fastui/events.py @@ -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'