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..e5fe6f78 100644 --- a/packages/fastui/src/components/ServerLoad.tsx +++ b/packages/fastui/src/components/ServerLoad.tsx @@ -1,42 +1,62 @@ 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, useEventListenerToggle } from '../events' 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 [loadFromServer] = useEventListenerToggle(loadTrigger) + + if (loadFromServer) { + 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() useEffect(() => { let fetchUrl = rootUrl if (pathSendMode === 'query') { - fetchUrl += `?path=${encodeURIComponent(url)}` + fetchUrl += `?path=${encodeURIComponent(path)}` } else { - fetchUrl += url + fetchUrl += path } - 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, 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..d5bd5594 100644 --- a/packages/fastui/src/components/modal.tsx +++ b/packages/fastui/src/components/modal.tsx @@ -1,7 +1,7 @@ import { FC, useEffect } from 'react' import { ClassName } from '../hooks/className' -import { PageEvent, useEventListenerToggle } from '../hooks/events' +import { PageEvent, useEventListenerToggle } from '../events' import { FastProps } from './index' 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/hooks/events.ts b/packages/fastui/src/events.ts similarity index 77% rename from packages/fastui/src/hooks/events.ts rename to packages/fastui/src/events.ts index 95fc6202..f37601f4 100644 --- a/packages/fastui/src/hooks/events.ts +++ b/packages/fastui/src/events.ts @@ -1,10 +1,11 @@ import { useContext, useState, useEffect, useCallback } from 'react' -import { LocationContext } from './locationContext' +import { LocationContext } from './hooks/locationContext' export interface PageEvent { type: 'page' name: string + pushPath?: string } export interface GoToEvent { @@ -33,6 +34,9 @@ export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } { const { type } = event switch (type) { case 'page': + if (event.pushPath) { + location.gotoCosmetic(event.pushPath) + } document.dispatchEvent(new CustomEvent(pageEventType(event))) break case 'go-to': @@ -47,6 +51,17 @@ export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } { 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 useEventListenerToggle(event?: PageEvent, initialState = false): [boolean, () => void] { const [state, setState] = useState(initialState) diff --git a/packages/fastui/src/hooks/locationContext.tsx b/packages/fastui/src/hooks/locationContext.tsx index 253f15c0..1a5cb298 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() @@ -79,6 +99,7 @@ export function LocationProvider({ children }: { children: ReactNode }) { } export function pathMatch(matchPath: string | boolean | undefined, fullPath: string): boolean { + console.log({ matchPath, fullPath }) if (typeof matchPath === 'string') { if (matchPath.startsWith('regex:')) { const regex = new RegExp(matchPath.slice(6)) diff --git a/packages/fastui/src/index.tsx b/packages/fastui/src/index.tsx index 6e2de5fb..ab1bbb85 100644 --- a/packages/fastui/src/index.tsx +++ b/packages/fastui/src/index.tsx @@ -8,10 +8,9 @@ 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' @@ -36,15 +35,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 dbce1016..55dd4b8c 100644 --- a/python/demo/main.py +++ b/python/demo/main.py @@ -90,7 +90,7 @@ def read_root() -> list[AnyComponent]: ), 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')), ], @@ -201,7 +201,8 @@ async def search_view(q: str) -> SelectSearchResponse: @app.get('/api/form/{kind}', response_model=FastUI, response_model_exclude_none=True) -def form_view(kind: str) -> list[AnyComponent]: +def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]: + other_form = 'two' if kind == 'one' else 'one' return [ navbar(), c.PageTitle(text='FastUI Demo - Form Examples'), @@ -217,17 +218,33 @@ def form_view(kind: str) -> list[AnyComponent]: ), c.Link( components=[c.Text(text='Form Two')], - on_click=GoToEvent(url='/form/two'), + on_click=PageEvent(name='change-form', push_path='/form/two'), active='/form/two', ), - c.Link( - components=[c.Text(text='Form Three')], - on_click=GoToEvent(url='/form/three'), - active='/form/three', - ), + # c.Link( + # components=[c.Text(text='Form Three')], + # on_click=GoToEvent(url='/form/three'), + # active='/form/three', + # ), ], mode='tabs', ), + c.ServerLoad( + path=f'/form/content/{other_form}', + 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'), @@ -237,8 +254,24 @@ def form_view(kind: str) -> 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..2cf534f7 100644 --- a/python/fastui/components/__init__.py +++ b/python/fastui/components/__init__.py @@ -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..e08ab694 100644 --- a/python/fastui/events.py +++ b/python/fastui/events.py @@ -5,6 +5,7 @@ class PageEvent(BaseModel): name: str + push_path: str | None = Field(default=None, serialization_alias='pushPath') type: Literal['page'] = 'page'