mirror of
https://github.com/samuelcolvin/FastUI.git
synced 2023-12-01 22:22:11 +03:00
changable nested elements (#9)
This commit is contained in:
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 <></>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 <></>
|
||||
}
|
||||
105
packages/fastui/src/events.ts
Normal file
105
packages/fastui/src/events.ts
Normal 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), []),
|
||||
}
|
||||
}
|
||||
33
packages/fastui/src/hooks/eventContext.tsx
Normal file
33
packages/fastui/src/hooks/eventContext.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user