Skip to content

Commit 0f0ea3b

Browse files
AIP-68 CLI to bootstrap a React App plugin (#53365)
* First working iteration * Remove apache licence when generating the project * Incorporate the theme, and small adjustments * Fix linting errors * Integrate to the airflow CLI * Update test structure * Add unit tests * Small adjustments * Address PR comment, build as library * Fix react issues * Smaller dependencies * Move back dump command to CLI plugins * Move back command to dev folder * Update airflow-core/src/airflow/cli/cli_config.py Co-authored-by: Jens Scheffler <95105677+jscheffl@users.noreply.github.com> --------- Co-authored-by: Jens Scheffler <95105677+jscheffl@users.noreply.github.com>
1 parent f896807 commit 0f0ea3b

26 files changed

+1178
-7
lines changed

airflow-core/src/airflow/ui/src/main.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import { ChakraProvider } from "@chakra-ui/react";
2020
import { QueryClientProvider } from "@tanstack/react-query";
2121
import axios, { type AxiosError } from "axios";
2222
import { StrictMode } from "react";
23+
import React from "react";
2324
import { createRoot } from "react-dom/client";
2425
import { I18nextProvider } from "react-i18next";
2526
import { RouterProvider } from "react-router-dom";
27+
import * as ReactJSXRuntime from "react/jsx-runtime";
2628

2729
import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
2830
import { ColorModeProvider } from "src/context/colorMode";
@@ -35,6 +37,12 @@ import { client } from "./queryClient";
3537
import { system } from "./theme";
3638
import { clearToken, tokenHandler } from "./utils/tokenHandler";
3739

40+
// Set React and ReactJSXRuntime on globalThis to share them with the dynamically imported React plugins.
41+
// Only one instance of React should be used.
42+
// Reflect will avoid type checking.
43+
Reflect.set(globalThis, "React", React);
44+
Reflect.set(globalThis, "ReactJSXRuntime", ReactJSXRuntime);
45+
3846
// redirect to login page if the API responds with unauthorized or forbidden errors
3947
axios.interceptors.response.use(
4048
(response) => response,

airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* under the License.
1818
*/
1919
import { Spinner } from "@chakra-ui/react";
20-
import { lazy, Suspense } from "react";
20+
import { type FC, lazy, Suspense } from "react";
2121
import { useParams } from "react-router-dom";
2222

2323
import type { ReactAppResponse } from "openapi/requests/types.gen";
@@ -29,13 +29,30 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse
2929

3030
const Plugin = lazy(() =>
3131
// We are assuming the plugin manager is trusted and the bundle_url is safe
32-
import(/* @vite-ignore */ reactApp.bundle_url).catch((error: unknown) => {
33-
console.error("Component Failed Loading:", error);
32+
import(/* @vite-ignore */ reactApp.bundle_url)
33+
.then(() => {
34+
const component = (
35+
globalThis as unknown as {
36+
AirflowPlugin: FC<{
37+
dagId?: string;
38+
mapIndex?: string;
39+
runId?: string;
40+
taskId?: string;
41+
}>;
42+
}
43+
).AirflowPlugin;
3444

35-
return {
36-
default: <ErrorPage />,
37-
};
38-
}),
45+
return {
46+
default: component,
47+
};
48+
})
49+
.catch((error: unknown) => {
50+
console.error("Component Failed Loading:", error);
51+
52+
return {
53+
default: ErrorPage,
54+
};
55+
}),
3956
);
4057

4158
return (

airflow-core/src/airflow/ui/tsconfig.node.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"noUnusedLocals": true,
1919
"noUnusedParameters": true,
2020
"noFallthroughCasesInSwitch": true,
21+
"noUncheckedIndexedAccess": true,
2122
"baseUrl": ".",
2223
"paths": {
2324
"src/*": ["./src/*"],

dev/react-plugin-tools/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
21+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
22+
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
23+
24+
- [React Plugin Development Tools](#react-plugin-development-tools)
25+
- [Overview](#overview)
26+
- [Files](#files)
27+
- [Quick Start](#quick-start)
28+
29+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
30+
31+
# React Plugin Development Tools
32+
33+
This directory contains tools for developing React-based Airflow plugins that can be dynamically loaded into the Airflow UI.
34+
35+
## Overview
36+
37+
These tools help you create React plugin projects that:
38+
39+
- Build as libraries compatible with dynamic imports
40+
- Share React instances with the host Airflow application
41+
- Follow Airflow's UI development patterns and standards
42+
- Include proper TypeScript configuration and build setup
43+
44+
## Files
45+
46+
- `bootstrap.py` - CLI tool to create new React plugin projects
47+
- `react_plugin_template/` - Template directory with all the necessary files
48+
49+
## Quick Start
50+
51+
### Create a New Plugin Project
52+
53+
```bash
54+
# From the dev/react-plugin-tools directory
55+
python bootstrap.py my-awesome-plugin
56+
57+
# Or specify a custom directory
58+
python bootstrap.py my-awesome-plugin --dir /path/to/my-projects/my-awesome-plugin
59+
```
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
"""
20+
Bootstrap React Plugin CLI Tool.
21+
22+
This script provides a command-line interface to create new React UI plugin
23+
directories based on the airflow-core/ui project structure. It sets up all the
24+
necessary configuration files, dependencies, and basic structure for development
25+
with the same tooling as used in Airflow's core UI.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
import re
32+
import shutil
33+
import sys
34+
from pathlib import Path
35+
36+
37+
def get_template_dir() -> Path:
38+
"""Get the template directory path."""
39+
script_dir = Path(__file__).parent
40+
template_dir = script_dir / "react_plugin_template"
41+
42+
if not template_dir.exists():
43+
print(f"Error: Template directory not found at {template_dir}")
44+
sys.exit(1)
45+
46+
return template_dir
47+
48+
49+
def replace_template_variables(content: str, project_name: str) -> str:
50+
"""Replace template variables in file content."""
51+
return content.replace("{{PROJECT_NAME}}", project_name)
52+
53+
54+
def remove_apache_license_header(content: str, file_extension: str) -> str:
55+
"""Remove Apache license header from file content based on file type."""
56+
if file_extension in [".ts", ".tsx", ".js", ".jsx"]:
57+
license_pattern = r"/\*!\s*\*\s*Licensed to the Apache Software Foundation.*?\*/\s*"
58+
content = re.sub(license_pattern, "", content, flags=re.DOTALL)
59+
elif file_extension in [".md"]:
60+
license_pattern = r"<!--\s*Licensed to the Apache Software Foundation.*?-->\s*"
61+
content = re.sub(license_pattern, "", content, flags=re.DOTALL)
62+
elif file_extension in [".html"]:
63+
license_pattern = r"<!--\s*Licensed to the Apache Software Foundation.*?-->\s*"
64+
content = re.sub(license_pattern, "", content, flags=re.DOTALL)
65+
66+
return content
67+
68+
69+
def copy_template_files(template_dir: Path, project_path: Path, project_name: str) -> None:
70+
for item in template_dir.rglob("*"):
71+
if item.is_file():
72+
# Calculate relative path from template root
73+
rel_path = item.relative_to(template_dir)
74+
target_path = project_path / rel_path
75+
76+
target_path.parent.mkdir(parents=True, exist_ok=True)
77+
78+
with open(item, encoding="utf-8") as f:
79+
content = f.read()
80+
81+
content = replace_template_variables(content, project_name)
82+
83+
file_extension = item.suffix.lower()
84+
content = remove_apache_license_header(content, file_extension)
85+
86+
with open(target_path, "w", encoding="utf-8") as f:
87+
f.write(content)
88+
89+
print(f" Created: {rel_path}")
90+
91+
92+
def bootstrap_react_plugin(args) -> None:
93+
"""Bootstrap a new React plugin project."""
94+
project_name = args.name
95+
target_dir = args.dir if args.dir else project_name
96+
97+
project_path = Path(target_dir).resolve()
98+
template_dir = get_template_dir()
99+
100+
if project_path.exists():
101+
print(f"Error: Directory '{project_path}' already exists!")
102+
sys.exit(1)
103+
104+
if not project_name.replace("-", "").replace("_", "").isalnum():
105+
print("Error: Project name should only contain letters, numbers, hyphens, and underscores")
106+
sys.exit(1)
107+
108+
print(f"Creating React plugin project: {project_name}")
109+
print(f"Target directory: {project_path}")
110+
print(f"Template directory: {template_dir}")
111+
112+
project_path.mkdir(parents=True, exist_ok=True)
113+
114+
try:
115+
# Copy template files
116+
print("Copying template files...")
117+
copy_template_files(template_dir, project_path, project_name)
118+
119+
print(f"\n✅ Successfully created {project_name}!")
120+
print("\nNext steps:")
121+
print(f" cd {target_dir}")
122+
print(" pnpm install")
123+
print(" pnpm dev")
124+
print("\nHappy coding! 🚀")
125+
126+
except Exception as e:
127+
print(f"Error creating project: {e}")
128+
if project_path.exists():
129+
shutil.rmtree(project_path)
130+
sys.exit(1)
131+
132+
133+
def main():
134+
"""Main CLI entry point."""
135+
parser = argparse.ArgumentParser(
136+
description="Bootstrap a new React UI plugin project",
137+
formatter_class=argparse.RawDescriptionHelpFormatter,
138+
epilog="""
139+
Examples:
140+
python bootstrap.py my-plugin
141+
python bootstrap.py my-plugin --dir /path/to/projects/my-plugin
142+
143+
This will create a new React project with all the necessary configuration
144+
files, dependencies, and structure needed for Airflow plugin development.
145+
""",
146+
)
147+
148+
parser.add_argument(
149+
"name",
150+
help="Name of the React plugin project (letters, numbers, hyphens, and underscores only)",
151+
)
152+
153+
parser.add_argument(
154+
"--dir",
155+
"-d",
156+
help="Target directory for the project (defaults to project name)",
157+
)
158+
159+
parser.add_argument(
160+
"--verbose",
161+
"-v",
162+
action="store_true",
163+
help="Enable verbose output",
164+
)
165+
166+
args = parser.parse_args()
167+
168+
try:
169+
bootstrap_react_plugin(args)
170+
except KeyboardInterrupt:
171+
print("\n\nOperation cancelled by user.")
172+
sys.exit(1)
173+
except Exception as e:
174+
print(f"Error: {e}")
175+
sys.exit(1)
176+
177+
178+
if __name__ == "__main__":
179+
main()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
.pnpm-store
12+
dist
13+
dist-ssr
14+
*.local
15+
16+
# Editor directories and files
17+
.vscode/*
18+
!.vscode/extensions.json
19+
.idea
20+
.DS_Store
21+
*.suo
22+
*.ntvs*
23+
*.njsproj
24+
*.sln
25+
*.sw?
26+
27+
# Coverage
28+
coverage
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
*.md
3+
*.yaml
4+
coverage/*
5+
node_modules/
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "http://json.schemastore.org/prettierrc",
3+
"endOfLine": "lf",
4+
"importOrder": ["<THIRD_PARTY_MODULES>", "^src/", "^[./]"],
5+
"importOrderSeparation": true,
6+
"jsxSingleQuote": false,
7+
"plugins": ["@trivago/prettier-plugin-sort-imports"],
8+
"printWidth": 110,
9+
"singleQuote": false,
10+
"tabWidth": 2,
11+
"trailingComma": "all",
12+
"useTabs": false
13+
}

0 commit comments

Comments
 (0)