diff --git a/biome.json b/biome.json index 67c39c2..c9b8a4a 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,8 @@ "!docs/.docusaurus", "!**/*.gen.css", "!**/*.gen.ts", - "!**/typedoc-sidebar.ts" + "!**/typedoc-sidebar.ts", + "!**/template" ] }, "formatter": { diff --git a/template/.env.example.tmpl b/template/.env.example.tmpl new file mode 100644 index 0000000..c8b5c44 --- /dev/null +++ b/template/.env.example.tmpl @@ -0,0 +1,7 @@ +DATABRICKS_HOST=https://... +{{- if .dotenv_example}} +{{.dotenv_example}} +{{- end}} +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=minimal +FLASK_RUN_HOST=0.0.0.0 diff --git a/template/.env.tmpl b/template/.env.tmpl new file mode 100644 index 0000000..62f5518 --- /dev/null +++ b/template/.env.tmpl @@ -0,0 +1,7 @@ +{{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} +{{- if .dotenv}} +{{.dotenv}} +{{- end}} +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME={{.project_name}} +FLASK_RUN_HOST=0.0.0.0 diff --git a/template/.gitignore.tmpl b/template/.gitignore.tmpl new file mode 100644 index 0000000..f2abc32 --- /dev/null +++ b/template/.gitignore.tmpl @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +client/dist/ +dist/ +build/ +.env +.databricks/ +.smoke-test/ +test-results/ +playwright-report/ diff --git a/template/.prettierignore b/template/.prettierignore new file mode 100644 index 0000000..7d3d77c --- /dev/null +++ b/template/.prettierignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules + +# Build outputs +dist +build +client/dist +.next +.databricks/ + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Coverage +coverage + +# Cache +.cache +.turbo + +# Lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Vendor +vendor diff --git a/template/.prettierrc.json b/template/.prettierrc.json new file mode 100644 index 0000000..d95a63f --- /dev/null +++ b/template/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "jsxSingleQuote": false +} diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..a39fb95 --- /dev/null +++ b/template/README.md @@ -0,0 +1,183 @@ +# Minimal Databricks App + +A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, tRPC, and Tailwind CSS. + +## Prerequisites + +- Node.js 18+ and npm +- Databricks CLI (for deployment) +- Access to a Databricks workspace + +## Databricks Authentication + +### Local Development + +For local development, configure your environment variables by creating a `.env` file: + +```bash +cp env.example .env +``` + +Edit `.env` and set the following: + +```env +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com +DATABRICKS_WAREHOUSE_ID=your-warehouse-id +DATABRICKS_APP_PORT=8000 +``` + +### CLI Authentication + +The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: + +#### OAuth U2M + +Interactive browser-based authentication with short-lived tokens: + +```bash +databricks auth login --host https://your-workspace.cloud.databricks.com +``` + +This will open your browser to complete authentication. The CLI saves credentials to `~/.databrickscfg`. + +#### Configuration Profiles + +Use multiple profiles for different workspaces: + +```ini +[DEFAULT] +host = https://dev-workspace.cloud.databricks.com + +[production] +host = https://prod-workspace.cloud.databricks.com +client_id = prod-client-id +client_secret = prod-client-secret +``` + +Deploy using a specific profile: + +```bash +databricks bundle deploy -t prod --profile production +``` + +**Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. + +## Getting Started + +### Install Dependencies + +```bash +npm install +``` + +### Development + +Run the app in development mode with hot reload: + +```bash +npm run dev +``` + +The app will be available at the URL shown in the console output. + +### Build + +Build both client and server for production: + +```bash +npm run build +``` + +This creates: + +- `dist/server/` - Compiled server code +- `client/dist/` - Bundled client assets + +### Production + +Run the production build: + +```bash +npm start +``` + +## Code Quality + +```bash +# Type checking +npm run typecheck + +# Linting +npm run lint +npm run lint:fix + +# Formatting +npm run format +npm run format:fix +``` + +## Deployment with Databricks Asset Bundles + +### 1. Configure Bundle + +Update `databricks.yml` with your workspace settings: + +```yaml +targets: + dev: + workspace: + host: https://your-workspace.cloud.databricks.com + variables: + warehouse_id: your-warehouse-id +``` + +### 2. Validate Bundle + +```bash +databricks bundle validate +``` + +### 3. Deploy + +Deploy to the development target: + +```bash +databricks bundle deploy -t dev +``` + +### 4. Run + +Start the deployed app: + +```bash +databricks bundle run -t dev +``` + +### Deploy to Production + +1. Configure the production target in `databricks.yml` +2. Deploy to production: + +```bash +databricks bundle deploy -t prod +``` + +## Project Structure + +``` +* client/ # React frontend + * src/ # Source code + * public/ # Static assets +* server/ # Express backend + * server.ts # Server entry point + * trpc.ts # tRPC router +* shared/ # Shared types +* databricks.yml # Bundle configuration +``` + +## Tech Stack + +- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS +- **Backend**: Node.js, Express, tRPC +- **UI Components**: Radix UI, shadcn/ui +- **Databricks**: App Kit SDK, Analytics SDK diff --git a/template/app.yaml.tmpl b/template/app.yaml.tmpl new file mode 100644 index 0000000..a4e7b27 --- /dev/null +++ b/template/app.yaml.tmpl @@ -0,0 +1,5 @@ +command: ['npm', 'run', 'start'] +{{- if .app_env}} +env: +{{.app_env}} +{{- end}} diff --git a/template/client/components.json b/template/client/components.json new file mode 100644 index 0000000..13e1db0 --- /dev/null +++ b/template/client/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/template/client/index.html b/template/client/index.html new file mode 100644 index 0000000..3c0f326 --- /dev/null +++ b/template/client/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + {{.project_name}} + + +
+ + + diff --git a/template/client/postcss.config.js b/template/client/postcss.config.js new file mode 100644 index 0000000..51a6e4e --- /dev/null +++ b/template/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/template/client/public/apple-touch-icon.png b/template/client/public/apple-touch-icon.png new file mode 100644 index 0000000..32053bd Binary files /dev/null and b/template/client/public/apple-touch-icon.png differ diff --git a/template/client/public/favicon-16x16.png b/template/client/public/favicon-16x16.png new file mode 100644 index 0000000..d7c16eb Binary files /dev/null and b/template/client/public/favicon-16x16.png differ diff --git a/template/client/public/favicon-192x192.png b/template/client/public/favicon-192x192.png new file mode 100644 index 0000000..8b4f18d Binary files /dev/null and b/template/client/public/favicon-192x192.png differ diff --git a/template/client/public/favicon-32x32.png b/template/client/public/favicon-32x32.png new file mode 100644 index 0000000..46aa684 Binary files /dev/null and b/template/client/public/favicon-32x32.png differ diff --git a/template/client/public/favicon-48x48.png b/template/client/public/favicon-48x48.png new file mode 100644 index 0000000..d2f89fb Binary files /dev/null and b/template/client/public/favicon-48x48.png differ diff --git a/template/client/public/favicon-512x512.png b/template/client/public/favicon-512x512.png new file mode 100644 index 0000000..14d7d20 Binary files /dev/null and b/template/client/public/favicon-512x512.png differ diff --git a/template/client/public/favicon.svg b/template/client/public/favicon.svg new file mode 100644 index 0000000..cb30c1e --- /dev/null +++ b/template/client/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/template/client/public/site.webmanifest b/template/client/public/site.webmanifest new file mode 100644 index 0000000..03106ce --- /dev/null +++ b/template/client/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "{{.project_name}}", + "short_name": "{{.project_name}}", + "icons": [ + { + "src": "/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx new file mode 100644 index 0000000..12cd542 --- /dev/null +++ b/template/client/src/App.tsx @@ -0,0 +1,155 @@ +import { + useAnalyticsQuery, + AreaChart, + LineChart, + RadarChart, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@databricks/appkit-ui/react'; +import { sql } from "@databricks/appkit-ui/js"; +import { useState, useEffect } from 'react'; + +function App() { + const { data, loading, error } = useAnalyticsQuery('hello_world', { + message: sql.string('hello world'), + }); + + const [health, setHealth] = useState<{ + status: string; + timestamp: string; + } | null>(null); + const [healthError, setHealthError] = useState(null); + + useEffect(() => { + fetch('/health') + .then((response) => response.json()) + .then((data: { status: string }) => setHealth({ ...data, timestamp: new Date().toISOString() })) + .catch((error: { message: string }) => setHealthError(error.message)); + }, []); + + const [maxMonthNum, setMaxMonthNum] = useState(12); + + const salesParameters = { max_month_num: sql.number(maxMonthNum) }; + + return ( +
+
+

Minimal Databricks App

+

A minimal Databricks App powered by Databricks AppKit

+
+ +
+ + + SQL Query Result + + + {loading && ( +
+ + +
+ )} + {error &&
Error: {error}
} + {data && data.length > 0 && ( +
+
Query: SELECT :message AS value
+
{data[0].value}
+
+ )} + {data && data.length === 0 &&
No results
} +
+
+ + + + Health Check + + + {!health && !healthError && ( +
+ + +
+ )} + {healthError && ( +
Error: {healthError}
+ )} + {health && ( +
+
+
+
{health.status.toUpperCase()}
+
+
+ Last checked: {new Date(health.timestamp).toLocaleString()} +
+
+ )} +
+
+ + + + Sales Data Filter + + +
+
+ + +
+
+
+
+ + + + Sales Trend Area Chart + + + + + + + + Sales Trend Custom Line Chart + + + + + + + + Sales Trend Radar Chart + + + + + +
+
+ ); +} + +export default App; diff --git a/template/client/src/ErrorBoundary.tsx b/template/client/src/ErrorBoundary.tsx new file mode 100644 index 0000000..6a73c26 --- /dev/null +++ b/template/client/src/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import type { ReactNode } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error); + console.error('Error details:', errorInfo); + this.setState({ + error, + errorInfo, + }); + } + + render() { + if (this.state.hasError) { + return ( +
+ + + Application Error + + +
+
+

Error Message:

+
{this.state.error?.toString()}
+
+ {this.state.errorInfo && ( +
+

Component Stack:

+
+                      {this.state.errorInfo.componentStack}
+                    
+
+ )} + {this.state.error?.stack && ( +
+

Stack Trace:

+
{this.state.error.stack}
+
+ )} +
+
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/template/client/src/index.css b/template/client/src/index.css new file mode 100644 index 0000000..0ce57a7 --- /dev/null +++ b/template/client/src/index.css @@ -0,0 +1,82 @@ +@import "@databricks/appkit-ui/styles.css"; + +:root { + /* --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.603 0.135 166.892); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.795 0.157 78.748); + --warning-foreground: oklch(0.199 0.027 238.732); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); */ +} + +@media (prefers-color-scheme: dark) { + :root { + /* --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.67 0.12 167); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.83 0.165 85); + --warning-foreground: oklch(0.199 0.027 238.732); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); */ + } +} diff --git a/template/client/src/lib/utils.ts b/template/client/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/template/client/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/template/client/src/main.tsx b/template/client/src/main.tsx new file mode 100644 index 0000000..35c59a5 --- /dev/null +++ b/template/client/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; +import { ErrorBoundary } from './ErrorBoundary.tsx'; + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/template/client/src/vite-env.d.ts b/template/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/template/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/template/client/tailwind.config.ts b/template/client/tailwind.config.ts new file mode 100644 index 0000000..31dece8 --- /dev/null +++ b/template/client/tailwind.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'tailwindcss'; +import tailwindcssAnimate from 'tailwindcss-animate'; + +const config: Config = { + darkMode: ['class', 'media'], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + plugins: [tailwindcssAnimate], +}; + +export default config; diff --git a/template/client/vite.config.ts b/template/client/vite.config.ts new file mode 100644 index 0000000..b49d405 --- /dev/null +++ b/template/client/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import path from 'node:path'; + +// https://vite.dev/config/ +export default defineConfig({ + root: __dirname, + plugins: [react(), tailwindcss()], + server: { + middlewareMode: true, + }, + build: { + outDir: path.resolve(__dirname, './dist'), + emptyOutDir: true, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'recharts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/template/config/queries/hello_world.sql b/template/config/queries/hello_world.sql new file mode 100644 index 0000000..31e480f --- /dev/null +++ b/template/config/queries/hello_world.sql @@ -0,0 +1 @@ +SELECT :message AS value; diff --git a/template/config/queries/mocked_sales.sql b/template/config/queries/mocked_sales.sql new file mode 100644 index 0000000..868e947 --- /dev/null +++ b/template/config/queries/mocked_sales.sql @@ -0,0 +1,18 @@ +WITH mock_data AS ( + SELECT 'January' AS month, 1 AS month_num, 65000 AS revenue, 45000 AS expenses, 850 AS customers + UNION ALL SELECT 'February', 2, 72000, 48000, 920 + UNION ALL SELECT 'March', 3, 78000, 52000, 1050 + UNION ALL SELECT 'April', 4, 85000, 55000, 1180 + UNION ALL SELECT 'May', 5, 92000, 58000, 1320 + UNION ALL SELECT 'June', 6, 88000, 54000, 1250 + UNION ALL SELECT 'July', 7, 95000, 60000, 1380 + UNION ALL SELECT 'August', 8, 89000, 56000, 1290 + UNION ALL SELECT 'September', 9, 82000, 53000, 1150 + UNION ALL SELECT 'October', 10, 87000, 55000, 1220 + UNION ALL SELECT 'November', 11, 93000, 59000, 1340 + UNION ALL SELECT 'December', 12, 98000, 62000, 1420 +) +SELECT * +FROM mock_data +WHERE month_num <= :max_month_num +ORDER BY month_num; diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl new file mode 100644 index 0000000..cdfa3fe --- /dev/null +++ b/template/databricks.yml.tmpl @@ -0,0 +1,36 @@ +bundle: + name: {{.project_name}} +{{if .bundle_variables}} + +variables: +{{.bundle_variables}} +{{- end}} + +resources: + apps: + app: + name: "{{.project_name}}" + description: "{{.app_description}}" + source_code_path: ./ + + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files + # user_api_scopes: + # - sql +{{if .bundle_resources}} + + # The resources which this app has access to. + resources: +{{.bundle_resources}} +{{- end}} + +targets: + default: + # mode: production + default: true + workspace: + host: {{workspace_host}} +{{if .target_variables}} + + variables: +{{.target_variables}} +{{- end}} diff --git a/template/eslint.config.js b/template/eslint.config.js new file mode 100644 index 0000000..5ac5ece --- /dev/null +++ b/template/eslint.config.js @@ -0,0 +1,91 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import reactRefreshPlugin from 'eslint-plugin-react-refresh'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + // Global ignores + { + ignores: [ + '**/dist/**', + '**/build/**', + '**/node_modules/**', + '**/.next/**', + '**/coverage/**', + 'client/dist/**', + '**.databricks/**', + 'tests/**', + '**/.smoke-test/**', + ], + }, + + // Base JavaScript config + js.configs.recommended, + + // TypeScript config for all TS files + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + + // React config for client-side files + { + files: ['client/**/*.{ts,tsx}', '**/*.tsx'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + 'react-refresh': reactRefreshPlugin, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + ...reactPlugin.configs.recommended.rules, + ...reactPlugin.configs['jsx-runtime'].rules, + ...reactHooksPlugin.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'react/prop-types': 'off', // Using TypeScript for prop validation + 'react/no-array-index-key': 'warn', + }, + }, + + // Node.js specific config for server files + { + files: ['server/**/*.ts', '*.config.{js,ts}'], + rules: { + '@typescript-eslint/no-var-requires': 'off', + }, + }, + + // Disable type-checking for JS config files and standalone config files + { + files: ['**/*.js', '*.config.ts', '**/*.config.ts'], + ...tseslint.configs.disableTypeChecked, + }, + + // Prettier config (must be last to override other formatting rules) + prettier, + + // Custom rules + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + }, + } +); diff --git a/template/features/analytics/app_env.yml b/template/features/analytics/app_env.yml new file mode 100644 index 0000000..9228a9d --- /dev/null +++ b/template/features/analytics/app_env.yml @@ -0,0 +1,2 @@ + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: warehouse diff --git a/template/features/analytics/bundle_resources.yml b/template/features/analytics/bundle_resources.yml new file mode 100644 index 0000000..b3a631c --- /dev/null +++ b/template/features/analytics/bundle_resources.yml @@ -0,0 +1,4 @@ + - name: 'warehouse' + sql_warehouse: + id: ${var.warehouse_id} + permission: 'CAN_USE' diff --git a/template/features/analytics/bundle_variables.yml b/template/features/analytics/bundle_variables.yml new file mode 100644 index 0000000..ac4fbf1 --- /dev/null +++ b/template/features/analytics/bundle_variables.yml @@ -0,0 +1,2 @@ + warehouse_id: + description: The ID of the warehouse to use diff --git a/template/features/analytics/dotenv.yml b/template/features/analytics/dotenv.yml new file mode 100644 index 0000000..7d17f13 --- /dev/null +++ b/template/features/analytics/dotenv.yml @@ -0,0 +1 @@ +DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/template/features/analytics/dotenv_example.yml b/template/features/analytics/dotenv_example.yml new file mode 100644 index 0000000..1ae1aa7 --- /dev/null +++ b/template/features/analytics/dotenv_example.yml @@ -0,0 +1 @@ +DATABRICKS_WAREHOUSE_ID= diff --git a/template/features/analytics/target_variables.yml b/template/features/analytics/target_variables.yml new file mode 100644 index 0000000..0de7b63 --- /dev/null +++ b/template/features/analytics/target_variables.yml @@ -0,0 +1 @@ + warehouse_id: {{.sql_warehouse_id}} diff --git a/template/package.json b/template/package.json new file mode 100644 index 0000000..f3f9d4f --- /dev/null +++ b/template/package.json @@ -0,0 +1,79 @@ +{ + "name": "{{.project_name}}", + "version": "1.0.0", + "main": "build/index.js", + "type": "module", + "scripts": { + "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server/server.js", + "dev": "NODE_ENV=development tsx watch --tsconfig ./tsconfig.server.json --env-file-if-exists=./.env ./server/server.ts", + "build:client": "tsc -b tsconfig.client.json && vite build --config client/vite.config.ts", + "build:server": "tsc -b tsconfig.server.json", + "build": "npm run build:server && npm run build:client", + "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:ast-grep": "appkit-lint", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "test": "vitest run && npm run test:smoke", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", + "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", + "typegen": "appkit-generate-types", + "setup": "appkit-setup --write" + }, + "keywords": [], + "author": "", + "license": "Unlicensed", + "description": "{{.app_description}}", + "dependencies": { + "@databricks/appkit": "0.1.4", + "@databricks/appkit-ui": "0.1.4", + "@databricks/sdk-experimental": "^0.14.2", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^0.546.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-resizable-panels": "^3.0.6", + "superjson": "^2.2.5", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.13" + }, + "devDependencies": { + "@ast-grep/napi": "^0.37.0", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", + "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/vite": "^4.1.17", + "@types/express": "^5.0.5", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "@typescript-eslint/parser": "^8.48.0", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.21", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "prettier": "^3.6.2", + "sharp": "^0.34.5", + "tailwindcss": "^4.0.14", + "tsx": "^4.20.6", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "npm:rolldown-vite@7.1.14", + "vitest": "^4.0.14" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } +} diff --git a/template/playwright.config.ts b/template/playwright.config.ts new file mode 100644 index 0000000..c4cad7a --- /dev/null +++ b/template/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: `http://localhost:${process.env.PORT || 8000}`, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: `http://localhost:${process.env.PORT || 8000}`, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/template/server/server.ts b/template/server/server.ts new file mode 100644 index 0000000..da04192 --- /dev/null +++ b/template/server/server.ts @@ -0,0 +1,8 @@ +import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; + +createApp({ + plugins: [ + server(), + {{.plugin_usage}}, + ], +}).catch(console.error); diff --git a/template/tests/smoke.spec.ts b/template/tests/smoke.spec.ts new file mode 100644 index 0000000..d712c69 --- /dev/null +++ b/template/tests/smoke.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +let testArtifactsDir: string; +let consoleLogs: string[] = []; +let consoleErrors: string[] = []; +let pageErrors: string[] = []; +let failedRequests: string[] = []; + +test('smoke test - app loads and displays data', async ({ page }) => { + // Navigate to the app + await page.goto('/'); + + // ⚠️ UPDATE THESE SELECTORS after customizing App.tsx: + // - Change heading name to match your app title + // - Change data selector to match your primary data display + await expect(page.getByRole('heading', { name: 'Minimal Databricks App' })).toBeVisible(); + await expect(page.getByText('hello world', { exact: true })).toBeVisible({ timeout: 30000 }); + + // Wait for health check to complete (wait for "OK" status) + await expect(page.getByText('OK')).toBeVisible({ timeout: 30000 }); + + // Verify console logs were captured + expect(consoleLogs.length).toBeGreaterThan(0); + expect(consoleErrors.length).toBe(0); + expect(pageErrors.length).toBe(0); +}); + +test.beforeEach(async ({ page }) => { + consoleLogs = []; + consoleErrors = []; + pageErrors = []; + failedRequests = []; + + // Create temp directory for test artifacts + testArtifactsDir = join(process.cwd(), '.smoke-test'); + mkdirSync(testArtifactsDir, { recursive: true }); + + // Capture console logs and errors (including React errors) + page.on('console', (msg) => { + const type = msg.type(); + const text = msg.text(); + + // Skip empty lines and formatting placeholders + if (!text.trim() || /^%[osd]$/.test(text.trim())) { + return; + } + + // Get stack trace for errors if available + const location = msg.location(); + const locationStr = location.url ? ` at ${location.url}:${location.lineNumber}:${location.columnNumber}` : ''; + + consoleLogs.push(`[${type}] ${text}${locationStr}`); + + // Separately track error messages (React errors appear here) + if (type === 'error') { + consoleErrors.push(`${text}${locationStr}`); + } + }); + + // Capture page errors with full stack trace + page.on('pageerror', (error) => { + const errorDetails = `Page error: ${error.message}\nStack: ${error.stack || 'No stack trace available'}`; + pageErrors.push(errorDetails); + // Also log to console for immediate visibility + console.error('Page error detected:', errorDetails); + }); + + // Capture failed requests + page.on('requestfailed', (request) => { + failedRequests.push(`Failed request: ${request.url()} - ${request.failure()?.errorText}`); + }); +}); + +test.afterEach(async ({ page }, testInfo) => { + const testName = testInfo.title.replace(/ /g, '-').toLowerCase(); + // Always capture artifacts, even if test fails + const screenshotPath = join(testArtifactsDir, `${testName}-app-screenshot.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + + const logsPath = join(testArtifactsDir, `${testName}-console-logs.txt`); + const allLogs = [ + '=== Console Logs ===', + ...consoleLogs, + '\n=== Console Errors (React errors) ===', + ...consoleErrors, + '\n=== Page Errors ===', + ...pageErrors, + '\n=== Failed Requests ===', + ...failedRequests, + ]; + writeFileSync(logsPath, allLogs.join('\n'), 'utf-8'); + + console.log(`Screenshot saved to: ${screenshotPath}`); + console.log(`Console logs saved to: ${logsPath}`); + if (consoleErrors.length > 0) { + console.log('Console errors detected:', consoleErrors); + } + if (pageErrors.length > 0) { + console.log('Page errors detected:', pageErrors); + } + if (failedRequests.length > 0) { + console.log('Failed requests detected:', failedRequests); + } + + await page.close(); +}); diff --git a/template/tsconfig.client.json b/template/tsconfig.client.json new file mode 100644 index 0000000..8732ba4 --- /dev/null +++ b/template/tsconfig.client.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.shared.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.client.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "erasableSyntaxOnly": true, + "noUncheckedSideEffectImports": true, + + /* Paths */ + "baseUrl": "./client", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["client/src"] +} diff --git a/template/tsconfig.json b/template/tsconfig.json new file mode 100644 index 0000000..a51fbad --- /dev/null +++ b/template/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.client.json" }, { "path": "./tsconfig.server.json" }] +} diff --git a/template/tsconfig.server.json b/template/tsconfig.server.json new file mode 100644 index 0000000..8cdada2 --- /dev/null +++ b/template/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.shared.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + "target": "ES2020", + "lib": ["ES2020"], + + /* Emit */ + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["server/**/*", "shared/**/*", "config/**/*"], + "exclude": ["node_modules", "dist", "client"] +} diff --git a/template/tsconfig.shared.json b/template/tsconfig.shared.json new file mode 100644 index 0000000..3187705 --- /dev/null +++ b/template/tsconfig.shared.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + + /* Modules */ + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + + /* Emit */ + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/template/vitest.config.ts b/template/vitest.config.ts new file mode 100644 index 0000000..98134fd --- /dev/null +++ b/template/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + exclude: ['**/node_modules/**', '**/dist/**', '**/*.spec.ts', '**/.smoke-test/**', '**/.databricks/**'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './client/src'), + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1238e62..2e612f1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ "**/*.config.*", "**/*.test.*", "**/tests/**", + "**/template/**", ], }, projects: [