diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c68c501..c6b61fd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,24 @@ jobs: env: SKIP: no-commit-to-branch + docs-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: '3.8' + + - name: install + run: | + pip install --upgrade pip + pip install -r requirements/docs.txt + + - name: build site + run: mkdocs build --strict + test: name: test ${{ matrix.python-version }} on ${{ matrix.os }} strategy: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d8d4b62..0b8d75a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,7 @@ repos: hooks: - id: no-commit-to-branch - id: check-yaml + args: ['--unsafe'] - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace diff --git a/Makefile b/Makefile index 023f415c..b30c3f3a 100644 --- a/Makefile +++ b/Makefile @@ -46,5 +46,13 @@ typescript-models: dev: uvicorn demo:app --reload --reload-dir . +.PHONY: docs +docs: + mkdocs build + +.PHONY: serve +serve: + mkdocs serve + .PHONY: all all: testcov lint diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 00000000..dac6c476 Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/logo-white.svg b/docs/assets/logo-white.svg new file mode 100644 index 00000000..61cc5bdb --- /dev/null +++ b/docs/assets/logo-white.svg @@ -0,0 +1,5 @@ + + + diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 00000000..6c431732 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,7 @@ +::: fastui.components +options: +docstring_options: +ignore_init_summary: false +members: - Text - Paragraph - PageTitle - Div - Page - Heading - Markdown - Code - Json - Button - Link - LinkList - Navbar - Modal - ServerLoad - Image - Iframe - FireEvent - Error - Spinner - Toast - Custom - Table - Pagination - Display - Details - Form - FormField - ModelForm - Footer - AnyComponent - FormFieldBoolean - FormFieldFile - FormFieldInput - FormFieldSelect - FormFieldSelectSearch + + diff --git a/docs/extra/tweaks.css b/docs/extra/tweaks.css new file mode 100644 index 00000000..0f6f7dba --- /dev/null +++ b/docs/extra/tweaks.css @@ -0,0 +1,5 @@ +/* Revert hue value to that of pre mkdocs-material v9.4.0 */ +[data-md-color-scheme='slate'] { + --md-hue: 230; + --md-default-bg-color: hsla(230, 15%, 21%, 1); +} diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..92b07c5a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,141 @@ +# FastUI + +## The Principle + +FastUI is a new way to build web application user interfaces defined by declarative Python code. + +This means: + +- **If you're a Python developer** — you can build responsive web applications using React without writing a single line of JavaScript, or touching `npm`. +- **If you're a frontend developer** — you can concentrate on building magical components that are truly reusable, no copy-pasting components for each view. +- **For everyone** — a true separation of concerns, the backend defines the entire application; while the frontend is free to implement just the user interface + +At its heart, FastUI is a set of matching [Pydantic](https://docs.pydantic.dev) models and TypeScript interfaces that allow you to define a user interface. This interface is validated at build time by TypeScript and pyright/mypy and at runtime by Pydantic. + +You can see a simple demo of an application built with FastUI [here](https://fastui-demo.onrender.com). + +## The Practice - Installation + +FastUI is made up of 4 things: + +- [`fastui` PyPI package](https://pypi.python.org/pypi/fastui) — Pydantic models for UI components, and some utilities. While it works well with [FastAPI](https://fastapi.tiangolo.com) it doesn't depend on FastAPI, and most of it could be used with any python web framework. +- [`@pydantic/fastui` npm package](https://www.npmjs.com/package/@pydantic/fastui) — a React TypeScript package that lets you reuse the machinery and types of FastUI while implementing your own components +- [`@pydantic/fastui-bootstrap` npm package](https://www.npmjs.com/package/@pydantic/fastui-bootstrap) — implementation/customisation of all FastUI components using [Bootstrap](https://getbootstrap.com) +- [`@pydantic/fastui-prebuilt` npm package](https://www.jsdelivr.com/package/npm/@pydantic/fastui-prebuilt) (available on [jsdelivr.com CDN](https://www.jsdelivr.com/package/npm/@pydantic/fastui-prebuilt)) providing a pre-built version of the FastUI React app so you can use it without installing any npm packages or building anything yourself. The Python package provides a simple HTML page to serve this app. + +## Usage + +Here's a simple but complete FastAPI application that uses FastUI to show some user profiles: + +```python +from datetime import date + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse +from fastui import FastUI, AnyComponent, prebuilt_html, components as c +from fastui.components.display import DisplayMode, DisplayLookup +from fastui.events import GoToEvent, BackEvent +from pydantic import BaseModel, Field + +app = FastAPI() + + +class User(BaseModel): + id: int + name: str + dob: date = Field(title='Date of Birth') + + +# define some users +users = [ + User(id=1, name='John', dob=date(1990, 1, 1)), + User(id=2, name='Jack', dob=date(1991, 1, 1)), + User(id=3, name='Jill', dob=date(1992, 1, 1)), + User(id=4, name='Jane', dob=date(1993, 1, 1)), +] + + +@app.get("/api/", response_model=FastUI, response_model_exclude_none=True) +def users_table() -> list[AnyComponent]: + """ + Show a table of four users, `/api` is the endpoint the frontend will connect to + when a user visits `/` to fetch components to render. + """ + return [ + c.Page( # Page provides a basic container for components + components=[ + c.Heading(text='Users', level=2), # renders `

Users

` + c.Table( + data=users, + # define two columns for the table + columns=[ + # the first is the users, name rendered as a link to their profile + DisplayLookup(field='name', on_click=GoToEvent(url='/user/{id}/')), + # the second is the date of birth, rendered as a date + DisplayLookup(field='dob', mode=DisplayMode.date), + ], + ), + ] + ), + ] + + +@app.get("/api/user/{user_id}/", response_model=FastUI, response_model_exclude_none=True) +def user_profile(user_id: int) -> list[AnyComponent]: + """ + User profile page, the frontend will fetch this when the user visits `/user/{id}/`. + """ + try: + user = next(u for u in users if u.id == user_id) + except StopIteration: + raise HTTPException(status_code=404, detail="User not found") + return [ + c.Page( + components=[ + c.Heading(text=user.name, level=2), + c.Link(components=[c.Text(text='Back')], on_click=BackEvent()), + c.Details(data=user), + ] + ), + ] + + +@app.get('/{path:path}') +async def html_landing() -> HTMLResponse: + """Simple HTML page which serves the React app, comes last as it matches all paths.""" + return HTMLResponse(prebuilt_html(title='FastUI Demo')) +``` + +Which renders like this: + +![screenshot](https://raw.githubusercontent.com/pydantic/FastUI/main/screenshot.png) + +Of course, that's a very simple application, the [full demo](https://fastui-demo.onrender.com) is more complete. + +### The Principle (long version) + +FastUI is an implementation of the RESTful principle; but not as it's usually understood, instead I mean the principle defined in the original [PhD dissertation](https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) by Roy Fielding, and excellently summarised in [this essay on htmx.org](https://htmx.org/essays/how-did-rest-come-to-mean-the-opposite-of-rest/) (HTMX people, I'm sorry to use your article to promote React which I know you despise 🙏). + +The RESTful principle as described in the HTMX article is that the frontend doesn't need to (and shouldn't) know anything about the application you're building. Instead, it should just provide all the components you need to construct the interface, the backend can then tell the frontend what to do. + +Think of your frontend as a puppet, and the backend as the hand within it — the puppet doesn't need to know what to say, that's kind of the point. + +Building an application this way has a number of significant advantages: + +- You only need to write code in one place to build a new feature — add a new view, change the behavior of an existing view or alter the URL structure +- Deploying the front and backend can be completely decoupled, provided the frontend knows how to render all the components the backend is going to ask it to use, you're good to go +- You should be able to reuse a rich set of opensource components, they should end up being better tested and more reliable than anything you could build yourself, this is possible because the components need no context about how they're going to be used (note: since FastUI is brand new, this isn't true yet, hopefully we get there) +- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema + +In the abstract, FastUI is like the opposite of GraphQL but with the same goal — GraphQL lets frontend developers extend an application without any new backend development; FastUI lets backend developers extend an application without any new frontend development. + +#### Beyond Python and React + +Of course, this principle shouldn't be limited to Python and React applications — provided we use the same set of agreed schemas and encoding to communicate, we should be able to use any frontend and backend that implements the schema. Interchangeably. + +This could mean: + +- Implementing a web frontend using another JS framework like Vue — lots of work, limited value +- Implementing a web frontend using an edge server, so the browser just sees HTML — lots of work but very valuable +- Implementing frontends for other platforms like mobile or IOT — lots of work, no idea if it's actually a good idea? +- Implementing the component models in another language like Rust or Go — since there's actually not that much code in the backend, so this would be a relatively small and mechanical task diff --git a/docs/plugins.py b/docs/plugins.py new file mode 100644 index 00000000..52af0177 --- /dev/null +++ b/docs/plugins.py @@ -0,0 +1,78 @@ +import logging +import os +import re + +from typing import Match + +from mkdocs.config import Config +from mkdocs.structure.files import Files +from mkdocs.structure.pages import Page + +try: + import pytest +except ImportError: + pytest = None + +logger = logging.getLogger('mkdocs.test_examples') + + +def on_pre_build(config: Config): + pass + + +def on_files(files: Files, config: Config) -> Files: + return remove_files(files) + + +def remove_files(files: Files) -> Files: + to_remove = [] + for file in files: + if file.src_path in {'plugins.py'}: + to_remove.append(file) + elif file.src_path.startswith('__pycache__/'): + to_remove.append(file) + + logger.debug('removing files: %s', [f.src_path for f in to_remove]) + for f in to_remove: + files.remove(f) + + return files + + +def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: + markdown = remove_code_fence_attributes(markdown) + return add_version(markdown, page) + + +def add_version(markdown: str, page: Page) -> str: + if page.file.src_uri == 'index.md': + version_ref = os.getenv('GITHUB_REF') + if version_ref and version_ref.startswith('refs/tags/'): + version = re.sub('^refs/tags/', '', version_ref.lower()) + url = f'https://github.com/samuelcolvin/dirty-equals/releases/tag/{version}' + version_str = f'Documentation for version: [{version}]({url})' + elif sha := os.getenv('GITHUB_SHA'): + sha = sha[:7] + url = f'https://github.com/samuelcolvin/dirty-equals/commit/{sha}' + version_str = f'Documentation for development version: [{sha}]({url})' + else: + version_str = 'Documentation for development version' + markdown = re.sub(r'{{ *version *}}', version_str, markdown) + return markdown + + +def remove_code_fence_attributes(markdown: str) -> str: + """ + There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use + `py key="value"` to provide attributes to pytest-examples, then remove those attributes here. + + https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/ + """ + + def remove_attrs(match: Match[str]) -> str: + suffix = re.sub( + r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M + ) + return f'{match.group(1)}{suffix}' + + return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..4012ad73 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,97 @@ +site_name: FastUI +site_description: Build web application user interfaces defined by declarative Python code. +site_url: https://docs.pydantic.dev/fastui/ + +theme: + name: 'material' + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: pink + accent: pink + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: pink + accent: pink + toggle: + icon: material/lightbulb + name: "Switch to light mode" + features: + - content.code.annotate + - content.tabs.link + - content.code.copy + - announce.dismiss + - navigation.tabs + - search.suggest + - search.highlight + logo: assets/logo-white.svg + favicon: assets/favicon.png + +repo_name: pydantic/FastUI +repo_url: https://github.com/pydantic/FastUI +edit_uri: '' + +# https://www.mkdocs.org/user-guide/configuration/#validation +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + +extra_css: + - 'extra/tweaks.css' + +# TODO: add flarelytics support +# extra_javascript: +# - '/flarelytics/client.js' + +markdown_extensions: + - toc: + permalink: true + - admonition + - pymdownx.details + - pymdownx.extra + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - attr_list + - md_in_html + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg +extra: + version: + provider: mike +watch: + - src +plugins: + - mike: + alias_type: symlink + canonical_version: latest + - search + - mkdocstrings: + handlers: + python: + paths: + - src/python-fastui + options: + members_order: source + separate_signature: true + docstring_options: + ignore_init_summary: true + merge_init_into_class: true + show_signature_annotations: true + signature_crossrefs: true + - mkdocs-simple-hooks: + hooks: + on_pre_build: 'docs.plugins:on_pre_build' + on_files: 'docs.plugins:on_files' + on_page_markdown: 'docs.plugins:on_page_markdown' +nav: + - Introduction: index.md + - Guide: guide.md + - Components: components.md diff --git a/package-lock.json b/package-lock.json index 1c67808a..2fe60cba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "workspaces": [ "src/*" ], + "dependencies": { + "prettier": "^3.2.5" + }, "devDependencies": { "@types/node": "^20.9.1", "@types/react": "^18.2.15", @@ -22,7 +25,6 @@ "eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-simple-import-sort": "^10.0.0", "json-schema-to-typescript": "^13.1.1", - "prettier": "^3.0.3", "typescript": "^5.0.2" } }, @@ -5559,10 +5561,9 @@ } }, "node_modules/prettier": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", - "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", - "dev": true, + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "bin": { "prettier": "bin/prettier.cjs" }, @@ -6954,7 +6955,7 @@ }, "src/npm-fastui": { "name": "@pydantic/fastui", - "version": "0.0.21", + "version": "0.0.22", "license": "MIT", "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", @@ -6971,7 +6972,7 @@ }, "src/npm-fastui-bootstrap": { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.21", + "version": "0.0.22", "license": "MIT", "dependencies": { "bootstrap": "^5.3.2", @@ -6981,12 +6982,12 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.21" + "@pydantic/fastui": "0.0.22" } }, "src/npm-fastui-prebuilt": { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.21", + "version": "0.0.22", "license": "MIT", "devDependencies": { "@vitejs/plugin-react-swc": "^3.3.2", diff --git a/package.json b/package.json index 98639d0a..ec220514 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-simple-import-sort": "^10.0.0", "json-schema-to-typescript": "^13.1.1", - "prettier": "^3.0.3", "typescript": "^5.0.2" + }, + "dependencies": { + "prettier": "^3.2.5" } } diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 00000000..90cc494b --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,7 @@ +mike +mkdocs +mkdocs-material +mkdocs-simple-hooks +mkdocstrings[python] +mkdocs-redirects +mkdocs-material-extensions diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 00000000..78515091 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,122 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements/docs.txt requirements/docs.in +# +babel==2.14.0 + # via mkdocs-material +certifi==2024.2.2 + # via requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # mkdocs + # mkdocstrings +colorama==0.4.6 + # via + # griffe + # mkdocs-material +ghp-import==2.1.0 + # via mkdocs +griffe==0.44.0 + # via mkdocstrings-python +idna==3.7 + # via requests +importlib-metadata==7.1.0 + # via mike +importlib-resources==6.4.0 + # via mike +jinja2==3.1.3 + # via + # mike + # mkdocs + # mkdocs-material + # mkdocstrings +markdown==3.6 + # via + # mkdocs + # mkdocs-autorefs + # mkdocs-material + # mkdocstrings + # pymdown-extensions +markupsafe==2.1.5 + # via + # jinja2 + # mkdocs + # mkdocs-autorefs + # mkdocstrings +mergedeep==1.3.4 + # via mkdocs +mike==2.0.0 + # via -r requirements/docs.in +mkdocs==1.5.3 + # via + # -r requirements/docs.in + # mike + # mkdocs-autorefs + # mkdocs-material + # mkdocs-redirects + # mkdocs-simple-hooks + # mkdocstrings +mkdocs-autorefs==1.0.1 + # via mkdocstrings +mkdocs-material==9.5.18 + # via -r requirements/docs.in +mkdocs-material-extensions==1.3.1 + # via + # -r requirements/docs.in + # mkdocs-material +mkdocs-redirects==1.2.1 + # via -r requirements/docs.in +mkdocs-simple-hooks==0.1.5 + # via -r requirements/docs.in +mkdocstrings[python]==0.24.3 + # via + # -r requirements/docs.in + # mkdocstrings-python +mkdocstrings-python==1.10.0 + # via mkdocstrings +packaging==24.0 + # via mkdocs +paginate==0.5.6 + # via mkdocs-material +pathspec==0.12.1 + # via mkdocs +platformdirs==4.2.0 + # via + # mkdocs + # mkdocstrings +pygments==2.17.2 + # via mkdocs-material +pymdown-extensions==10.8 + # via + # mkdocs-material + # mkdocstrings +pyparsing==3.1.2 + # via mike +python-dateutil==2.9.0.post0 + # via ghp-import +pyyaml==6.0.1 + # via + # mike + # mkdocs + # pymdown-extensions + # pyyaml-env-tag +pyyaml-env-tag==0.1 + # via mkdocs +regex==2024.4.16 + # via mkdocs-material +requests==2.31.0 + # via mkdocs-material +six==1.16.0 + # via python-dateutil +urllib3==2.2.1 + # via requests +verspec==0.1.0 + # via mike +watchdog==4.0.0 + # via mkdocs +zipp==3.18.1 + # via importlib-metadata diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index e304607f..c5769ed3 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -70,35 +70,47 @@ export type DisplayMode = | 'inline_code' export type SelectOptions = SelectOption[] | SelectGroup[] +/** + * Text component that displays a string. + */ export interface Text { text: string type: 'Text' } +/** + * Paragraph component that displays a string as a paragraph. + */ export interface Paragraph { text: string className?: ClassName type: 'Paragraph' } /** - * This sets the title of the HTML page via the `document.title` property. + * Sets the title of the HTML page via the `document.title` property. */ export interface PageTitle { text: string type: 'PageTitle' } +/** + * A generic container component. + */ export interface Div { components: FastProps[] className?: ClassName type: 'Div' } /** - * Similar to `container` in many UI frameworks, this should be a reasonable root component for most pages. + * Similar to `container` in many UI frameworks, this acts as a root component for most pages. */ export interface Page { components: FastProps[] className?: ClassName type: 'Page' } +/** + * Heading component. + */ export interface Heading { text: string level: 1 | 2 | 3 | 4 | 5 | 6 @@ -106,12 +118,18 @@ export interface Heading { className?: ClassName type: 'Heading' } +/** + * Markdown component that renders markdown text. + */ export interface Markdown { text: string codeStyle?: string className?: ClassName type: 'Markdown' } +/** + * Code component that renders code with syntax highlighting. + */ export interface Code { text: string language?: string @@ -119,11 +137,17 @@ export interface Code { className?: ClassName type: 'Code' } +/** + * JSON component that renders JSON data. + */ export interface Json { value: JsonData className?: ClassName type: 'JSON' } +/** + * Button component. + */ export interface Button { text: string onClick?: AnyEvent @@ -159,6 +183,9 @@ export interface AuthEvent { url?: string type: 'auth' } +/** + * Link component. + */ export interface Link { components: FastProps[] onClick?: PageEvent | GoToEvent | BackEvent | AuthEvent @@ -168,12 +195,18 @@ export interface Link { className?: ClassName type: 'Link' } +/** + * List of Link components. + */ export interface LinkList { links: Link[] mode?: 'tabs' | 'vertical' | 'pagination' className?: ClassName type: 'LinkList' } +/** + * Navbar component used for moving between pages. + */ export interface Navbar { title?: string titleEvent?: PageEvent | GoToEvent | BackEvent | AuthEvent @@ -182,12 +215,18 @@ export interface Navbar { className?: ClassName type: 'Navbar' } +/** + * Footer component. + */ export interface Footer { links: Link[] extraText?: string className?: ClassName type: 'Footer' } +/** + * Modal component that displays a modal dialog. + */ export interface Modal { title: string body: FastProps[] @@ -209,6 +248,9 @@ export interface ServerLoad { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' type: 'ServerLoad' } +/** + * Image container component. + */ export interface Image { src: string alt?: string @@ -228,6 +270,9 @@ export interface Image { className?: ClassName type: 'Image' } +/** + * Iframe component that displays content from a URL. + */ export interface Iframe { src: string title?: string @@ -238,6 +283,9 @@ export interface Iframe { sandbox?: string type: 'Iframe' } +/** + * Video component that displays a video or multiple videos. + */ export interface Video { sources: string[] autoplay?: boolean @@ -247,14 +295,20 @@ export interface Video { poster?: string width?: string | number height?: string | number - type: 'Video' className?: ClassName + type: 'Video' } +/** + * Fire an event. + */ export interface FireEvent { event: AnyEvent message?: string type: 'FireEvent' } +/** + * Utility component used to display an error. + */ export interface Error { title: string description: string @@ -263,11 +317,17 @@ export interface Error { type: 'Error' children?: ReactNode } +/** + * Spinner component that displays a loading spinner. + */ export interface Spinner { text?: string className?: ClassName type: 'Spinner' } +/** + * Custom component that allows for special data to be rendered. + */ export interface Custom { data: JsonData subType: string @@ -462,6 +522,9 @@ export interface ModelForm { | FormFieldSelectSearch )[] } +/** + * Toast component that displays a toast message (small temporary message). + */ export interface Toast { title: string body: FastProps[] diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 6594863e..008da1ad 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -1,8 +1,6 @@ """ Component definitions. -NOTE: all imports should be "simple" so the namespace of the module is polluted as little as possible. - All CamelCase names in the namespace should be components. """ import typing as _t @@ -49,6 +47,7 @@ 'FireEvent', 'Error', 'Spinner', + 'Toast', 'Custom', # then we include components from other files 'Table', @@ -71,47 +70,81 @@ class Text(_p.BaseModel, extra='forbid'): + """Text component that displays a string.""" + text: str + """The text to display.""" + type: _t.Literal['Text'] = 'Text' + """The type of the component. Always 'Text'.""" class Paragraph(_p.BaseModel, extra='forbid'): + """Paragraph component that displays a string as a paragraph.""" + text: str + """The text to display.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the paragraph's HTML component.""" + type: _t.Literal['Paragraph'] = 'Paragraph' + """The type of the component. Always 'Paragraph'.""" class PageTitle(_p.BaseModel, extra='forbid'): - """ - This sets the title of the HTML page via the `document.title` property. - """ + """Sets the title of the HTML page via the `document.title` property.""" text: str + """The text to set as the page title.""" + type: _t.Literal['PageTitle'] = 'PageTitle' + """The type of the component. Always 'PageTitle'.""" class Div(_p.BaseModel, extra='forbid'): + """A generic container component.""" + components: '_t.List[AnyComponent]' + """List of components to render inside the div.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the div's HTML component.""" + type: _t.Literal['Div'] = 'Div' + """The type of the component. Always 'Div'.""" class Page(_p.BaseModel, extra='forbid'): - """ - Similar to `container` in many UI frameworks, this should be a reasonable root component for most pages. - """ + """Similar to `container` in many UI frameworks, this acts as a root component for most pages.""" components: '_t.List[AnyComponent]' + """List of components to render on the page.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the page's HTML component.""" + type: _t.Literal['Page'] = 'Page' + """The type of the component. Always 'Page'.""" class Heading(_p.BaseModel, extra='forbid'): + """Heading component.""" + text: str + """The text to display in the heading.""" + level: _t.Literal[1, 2, 3, 4, 5, 6] = 1 + """The level of the heading. 1 is the largest, 6 is the smallest.""" + html_id: _t.Union[str, None] = _p.Field(default=None, serialization_alias='htmlId') + """Optional HTML ID to apply to the heading's HTML component.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the page's HTML component.""" + type: _t.Literal['Heading'] = 'Heading' + """The type of the component. Always 'Heading'.""" @classmethod def __get_pydantic_json_schema__( @@ -126,64 +159,151 @@ def __get_pydantic_json_schema__( # see https://github.com/PrismJS/prism-themes # and https://cdn.jsdelivr.net/npm/react-syntax-highlighter@15.5.0/dist/esm/styles/prism/index.js CodeStyle = _te.Annotated[_t.Union[str, None], _p.Field(serialization_alias='codeStyle')] +""" +Code style to apply to a `Code` component. + +Attributes: + codeStyle: The code style to apply. If None, no style is applied. + +See Also: + - https://github.com/PrismJS/prism-themes + - https://cdn.jsdelivr.net/npm/react-syntax-highlighter@15.5.0/dist/esm/styles/prism/index.js +""" class Markdown(_p.BaseModel, extra='forbid'): + """Markdown component that renders markdown text.""" + text: str + """The markdown text to render.""" + code_style: CodeStyle = None + """Optional code style to apply to the markdown text.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the page's HTML component.""" + type: _t.Literal['Markdown'] = 'Markdown' + """The type of the component. Always 'Markdown'.""" class Code(_p.BaseModel, extra='forbid'): + """Code component that renders code with syntax highlighting.""" + text: str + """The code to render.""" + language: _t.Union[str, None] = None + """Optional language of the code. If None, no syntax highlighting is applied.""" + code_style: CodeStyle = None + """Optional code style to apply to the code.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the page's HTML component.""" + type: _t.Literal['Code'] = 'Code' + """The type of the component. Always 'Code'.""" class Json(_p.BaseModel, extra='forbid'): + """JSON component that renders JSON data.""" + value: _types.JsonData + """The JSON data to render.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the page's HTML component.""" + type: _t.Literal['JSON'] = 'JSON' + """The type of the component. Always 'JSON'.""" class Button(_p.BaseModel, extra='forbid'): + """Button component.""" + text: str + """The text to display on the button.""" + on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + """Optional event to trigger when the button is clicked.""" + html_type: _t.Union[_t.Literal['button', 'reset', 'submit'], None] = _p.Field( default=None, serialization_alias='htmlType' ) + """Optional HTML type of the button. If None, defaults to 'button'.""" + named_style: _class_name.NamedStyleField = None + """Optional named style to apply to the button.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the button's HTML component.""" + type: _t.Literal['Button'] = 'Button' + """The type of the component. Always 'Button'.""" class Link(_p.BaseModel, extra='forbid'): + """Link component.""" + components: '_t.List[AnyComponent]' + """List of components to render attached to the link.""" + on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + """Optional event to trigger when the link is clicked.""" + mode: _t.Union[_t.Literal['navbar', 'footer', 'tabs', 'vertical', 'pagination'], None] = None + """Optional mode of the link.""" + active: _t.Union[str, bool, None] = None + """Optional active state of the link.""" + locked: _t.Union[bool, None] = None + """Optional locked state of the link.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the link's HTML component.""" + type: _t.Literal['Link'] = 'Link' + """The type of the component. Always 'Link'.""" class LinkList(_p.BaseModel, extra='forbid'): + """List of Link components.""" + links: _t.List[Link] + """List of links to render.""" + mode: _t.Union[_t.Literal['tabs', 'vertical', 'pagination'], None] = None + """Optional mode of the link list.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the link list's HTML component.""" + type: _t.Literal['LinkList'] = 'LinkList' + """The type of the component. Always 'LinkList'.""" class Navbar(_p.BaseModel, extra='forbid'): + """Navbar component used for moving between pages.""" + title: _t.Union[str, None] = None + """Optional title to display in the navbar.""" + title_event: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='titleEvent') + """Optional event to trigger when the title is clicked. Often used to navigate to the home page.""" + start_links: _t.List[Link] = _p.Field(default=[], serialization_alias='startLinks') + """List of links to render at the start of the navbar.""" + end_links: _t.List[Link] = _p.Field(default=[], serialization_alias='endLinks') + """List of links to render at the end of the navbar.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the navbar's HTML component.""" + type: _t.Literal['Navbar'] = 'Navbar' + """The type of the component. Always 'Navbar'.""" @classmethod def __get_pydantic_json_schema__( @@ -196,41 +316,86 @@ def __get_pydantic_json_schema__( class Footer(_p.BaseModel, extra='forbid'): + """Footer component.""" + links: _t.List[Link] + """List of links to render in the footer.""" + extra_text: _t.Union[str, None] = _p.Field(default=None, serialization_alias='extraText') + """Optional extra text to display in the footer.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the footer's HTML component.""" + type: _t.Literal['Footer'] = 'Footer' + """The type of the component. Always 'Footer'.""" class Modal(_p.BaseModel, extra='forbid'): + """Modal component that displays a modal dialog.""" + title: str + """The text displayed on the modal trigger button.""" + body: '_t.List[AnyComponent]' + """List of components to render in the modal body.""" + footer: '_t.Union[_t.List[AnyComponent], None]' = None + """Optional list of components to render in the modal footer.""" + open_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='openTrigger') + """Optional event to trigger when the modal is opened.""" + open_context: _t.Union[events.ContextType, None] = _p.Field(default=None, serialization_alias='openContext') + """Optional context to pass to the open trigger event.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the modal's HTML component.""" + type: _t.Literal['Modal'] = 'Modal' + """The type of the component. Always 'Modal'.""" class ServerLoad(_p.BaseModel, extra='forbid'): - """ - A component that will be replaced by the server with the component returned by the given URL. - """ + """A component that will be replaced by the server with the component returned by the given URL.""" path: str + """The URL to load the component from.""" + load_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='loadTrigger') + """Optional event to trigger when the component is loaded.""" + components: '_t.Union[_t.List[AnyComponent], None]' = None + """Optional list of components to render while the server is loading the new component(s).""" + sse: _t.Union[bool, None] = None + """Optional flag to enable server-sent events (SSE) for the server load.""" + sse_retry: _t.Union[int, None] = _p.Field(default=None, serialization_alias='sseRetry') + """Optional time in milliseconds to retry the SSE connection.""" + method: _t.Union[_t.Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], None] = None + """Optional HTTP method to use when loading the component.""" + type: _t.Literal['ServerLoad'] = 'ServerLoad' + """The type of the component. Always 'ServerLoad'.""" class Image(_p.BaseModel, extra='forbid'): + """Image container component.""" + src: str + """The URL of the image to display.""" + alt: _t.Union[str, None] = None + """Optional alt text for the image.""" + width: _t.Union[str, int, None] = None + """Optional width used to display the image.""" + height: _t.Union[str, int, None] = None + """Optional height used to display the image.""" + referrer_policy: _t.Union[ _t.Literal[ 'no-referrer', @@ -244,48 +409,115 @@ class Image(_p.BaseModel, extra='forbid'): ], None, ] = _p.Field(None, serialization_alias='referrerPolicy') + """Optional referrer policy for the image. Specifies what information to send when fetching the image. + + For more info, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy.""" + loading: _t.Union[_t.Literal['eager', 'lazy'], None] = None + """Optional loading strategy for the image.""" + on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + """Optional event to trigger when the image is clicked.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the image's HTML component.""" + type: _t.Literal['Image'] = 'Image' + """The type of the component. Always 'Image'.""" class Iframe(_p.BaseModel, extra='forbid'): + """Iframe component that displays content from a URL.""" + src: _p.HttpUrl + """The URL of the content to display.""" + title: _t.Union[str, None] = None + """Optional title for the iframe.""" + width: _t.Union[str, int, None] = None + """Optional width used to display the iframe.""" + height: _t.Union[str, int, None] = None + """Optional height used to display the iframe.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the iframe's HTML component.""" + srcdoc: _t.Union[str, None] = None + """Optional HTML content to display in the iframe.""" + sandbox: _t.Union[str, None] = None + """Optional sandbox policy for the iframe. Specifies restrictions on the HTML content in the iframe.""" + type: _t.Literal['Iframe'] = 'Iframe' + """The type of the component. Always 'Iframe'.""" class Video(_p.BaseModel, extra='forbid'): + """Video component that displays a video or multiple videos.""" + sources: _t.List[_p.AnyUrl] + """List of URLs to the video sources.""" + autoplay: _t.Union[bool, None] = None + """Optional flag to enable autoplay for the video.""" + controls: _t.Union[bool, None] = None + """Optional flag to enable controls (pause, play, etc) for the video.""" + loop: _t.Union[bool, None] = None + """Optional flag to enable looping for the video.""" + muted: _t.Union[bool, None] = None + """Optional flag to mute the video.""" + poster: _t.Union[_p.AnyUrl, None] = None + """Optional URL to an image to display as the video poster (what is shown when the video is loading or until the user plays it).""" + width: _t.Union[str, int, None] = None + """Optional width used to display the video.""" + height: _t.Union[str, int, None] = None - type: _t.Literal['Video'] = 'Video' + """Optional height used to display the video.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the video's HTML component.""" + + type: _t.Literal['Video'] = 'Video' + """The type of the component. Always 'Video'.""" class FireEvent(_p.BaseModel, extra='forbid'): + """Fire an event.""" + event: events.AnyEvent - message: _t.Union[str, None] = None # defaults to blank + """The event to fire.""" + + message: _t.Union[str, None] = None + """Optional message to display when the event is fired. Defaults to a blank message.""" + type: _t.Literal['FireEvent'] = 'FireEvent' + """The type of the component. Always 'FireEvent'.""" class Error(_p.BaseModel, extra='forbid'): + """Utility component used to display an error.""" + title: str + """The title of the error.""" + description: str + """The description of the error.""" + status_code: _t.Union[int, None] = _p.Field(None, serialization_alias='statusCode') + """Optional status code of the error.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the error's HTML component.""" + type: _t.Literal['Error'] = 'Error' + """The type of the component. Always 'Error'.""" @classmethod def __get_pydantic_json_schema__( @@ -298,14 +530,28 @@ def __get_pydantic_json_schema__( class Spinner(_p.BaseModel, extra='forbid'): + """Spinner component that displays a loading spinner.""" + text: _t.Union[str, None] = None + """Optional text to display with the spinner.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the spinner's HTML component.""" + type: _t.Literal['Spinner'] = 'Spinner' + """The type of the component. Always 'Spinner'.""" class Toast(_p.BaseModel, extra='forbid'): + """Toast component that displays a toast message (small temporary message).""" + title: str + """The title of the toast.""" + body: '_t.List[AnyComponent]' + """List of components to render in the toast body.""" + + # TODO: change these before the release (top left, center, end, etc). Can be done with the toast bug fix. position: _t.Union[ _t.Literal[ 'top-start', @@ -320,18 +566,38 @@ class Toast(_p.BaseModel, extra='forbid'): ], None, ] = None + """Optional position of the toast.""" + open_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='openTrigger') + """Optional event to trigger when the toast is opened.""" + open_context: _t.Union[events.ContextType, None] = _p.Field(default=None, serialization_alias='openContext') + """Optional context to pass to the open trigger event.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the toast's HTML component.""" + type: _t.Literal['Toast'] = 'Toast' + """The type of the component. Always 'Toast'.""" class Custom(_p.BaseModel, extra='forbid'): + """Custom component that allows for special data to be rendered.""" + data: _types.JsonData + """The data to render in the custom component.""" + sub_type: str = _p.Field(serialization_alias='subType') + """The sub-type of the custom component.""" + library: _t.Union[str, None] = None + """Optional library to use for the custom component.""" + class_name: _class_name.ClassNameField = None + """Optional class name to apply to the custom component's HTML component.""" + type: _t.Literal['Custom'] = 'Custom' + """The type of the component. Always 'Custom'.""" AnyComponent = _te.Annotated[ @@ -370,3 +636,6 @@ class Custom(_p.BaseModel, extra='forbid'): ], _p.Field(discriminator='type'), ] +"""Union of all components. + +Pydantic discriminator field is set to 'type' to allow for efficient serialization and deserialization of the components."""