From a0c98a42436b4ea4bd5fae9dff763f0fdc28ac00 Mon Sep 17 00:00:00 2001 From: Valay Dave Date: Fri, 19 Sep 2025 10:29:09 -0700 Subject: [PATCH 1/2] [cards] valuebox component --- metaflow/cards.py | 1 + .../plugins/cards/card_modules/components.py | 228 ++++++++++++++++++ .../components/card-component-renderer.svelte | 2 + .../src/components/table-data-renderer.svelte | 2 + .../cards/ui/src/components/value-box.svelte | 165 +++++++++++++ metaflow/plugins/cards/ui/src/types.ts | 12 + 6 files changed, 410 insertions(+) create mode 100644 metaflow/plugins/cards/ui/src/components/value-box.svelte diff --git a/metaflow/cards.py b/metaflow/cards.py index 2497d4dd89c..334b3dcfb27 100644 --- a/metaflow/cards.py +++ b/metaflow/cards.py @@ -8,6 +8,7 @@ Markdown, VegaChart, ProgressBar, + ValueBox, PythonCode, ) from metaflow.plugins.cards.card_modules.basic import ( diff --git a/metaflow/plugins/cards/card_modules/components.py b/metaflow/plugins/cards/card_modules/components.py index 65685cc889a..32b66146b4b 100644 --- a/metaflow/plugins/cards/card_modules/components.py +++ b/metaflow/plugins/cards/card_modules/components.py @@ -753,6 +753,234 @@ def render(self): return data +class ValueBox(UserComponent): + """ + A Value Box component for displaying key metrics with styling and change indicators. + + Inspired by Shiny's value box component, this displays a primary value with optional + title, subtitle, theme, and change indicators. + + Example: + ``` + # Basic value box + value_box = ValueBox( + title="Revenue", + value="$1.2M", + subtitle="Monthly Revenue", + change_indicator="Up 15% from last month" + ) + current.card.append(value_box) + + # Themed value box + value_box = ValueBox( + title="Total Savings", + value=50000, + theme="success", + change_indicator="Up 30% from last month" + ) + current.card.append(value_box) + + # Updatable value box for real-time metrics + metrics_box = ValueBox( + title="Processing Progress", + value=0, + subtitle="Items processed" + ) + current.card.append(metrics_box) + + for i in range(1000): + metrics_box.update(value=i, change_indicator=f"Rate: {i*2}/sec") + ``` + + Parameters + ---------- + title : str, optional + The title/label for the value box (usually displayed above the value). + Must be 200 characters or less. + value : Union[str, int, float] + The main value to display prominently. Required parameter. + subtitle : str, optional + Additional descriptive text displayed below the title. + Must be 300 characters or less. + theme : str, optional + CSS class name for styling the value box. Supported themes: 'default', 'success', + 'warning', 'danger', 'bg-gradient-indigo-purple'. Custom themes must be valid CSS class names. + change_indicator : str, optional + Text indicating change or additional context (e.g., "Up 30% VS PREVIOUS 30 DAYS"). + Must be 200 characters or less. + """ + + type = "valueBox" + + REALTIME_UPDATABLE = True + + # Valid built-in themes + VALID_THEMES = { + "default", + "success", + "warning", + "danger", + "bg-gradient-indigo-purple", + } + + def __init__( + self, + title: Optional[str] = None, + value: Union[str, int, float] = "", + subtitle: Optional[str] = None, + theme: Optional[str] = None, + change_indicator: Optional[str] = None, + ): + # Validate inputs + self._validate_title(title) + self._validate_value(value) + self._validate_subtitle(subtitle) + self._validate_theme(theme) + self._validate_change_indicator(change_indicator) + + self._title = title + self._value = value + self._subtitle = subtitle + self._theme = theme + self._change_indicator = change_indicator + + def update( + self, + title: Optional[str] = None, + value: Optional[Union[str, int, float]] = None, + subtitle: Optional[str] = None, + theme: Optional[str] = None, + change_indicator: Optional[str] = None, + ): + """ + Update the value box with new data. + + Parameters + ---------- + title : str, optional + New title for the value box. + value : Union[str, int, float], optional + New value to display. + subtitle : str, optional + New subtitle text. + theme : str, optional + New theme/styling class. + change_indicator : str, optional + New change indicator text. + """ + if title is not None: + self._validate_title(title) + self._title = title + if value is not None: + self._validate_value(value) + self._value = value + if subtitle is not None: + self._validate_subtitle(subtitle) + self._subtitle = subtitle + if theme is not None: + self._validate_theme(theme) + self._theme = theme + if change_indicator is not None: + self._validate_change_indicator(change_indicator) + self._change_indicator = change_indicator + + def _validate_title(self, title: Optional[str]) -> None: + """Validate title parameter.""" + if title is not None: + if not isinstance(title, str): + raise TypeError(f"Title must be a string, got {type(title).__name__}") + if len(title) > 200: + raise ValueError( + f"Title must be 200 characters or less, got {len(title)} characters" + ) + if not title.strip(): + raise ValueError("Title cannot be empty or whitespace only") + + def _validate_value(self, value: Union[str, int, float]) -> None: + """Validate value parameter.""" + if value is None: + raise ValueError("Value is required and cannot be None") + if not isinstance(value, (str, int, float)): + raise TypeError( + f"Value must be str, int, or float, got {type(value).__name__}" + ) + if isinstance(value, str): + if len(value) > 100: + raise ValueError( + f"String value must be 100 characters or less, got {len(value)} characters" + ) + if not value.strip(): + raise ValueError("String value cannot be empty or whitespace only") + if isinstance(value, (int, float)): + if not (-1e15 <= value <= 1e15): + raise ValueError( + f"Numeric value must be between -1e15 and 1e15, got {value}" + ) + + def _validate_subtitle(self, subtitle: Optional[str]) -> None: + """Validate subtitle parameter.""" + if subtitle is not None: + if not isinstance(subtitle, str): + raise TypeError( + f"Subtitle must be a string, got {type(subtitle).__name__}" + ) + if len(subtitle) > 300: + raise ValueError( + f"Subtitle must be 300 characters or less, got {len(subtitle)} characters" + ) + if not subtitle.strip(): + raise ValueError("Subtitle cannot be empty or whitespace only") + + def _validate_theme(self, theme: Optional[str]) -> None: + """Validate theme parameter.""" + if theme is not None: + if not isinstance(theme, str): + raise TypeError(f"Theme must be a string, got {type(theme).__name__}") + if not theme.strip(): + raise ValueError("Theme cannot be empty or whitespace only") + # Allow custom themes but warn if not in valid set + if theme not in self.VALID_THEMES: + import re + + # Basic CSS class name validation + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", theme): + raise ValueError( + f"Theme must be a valid CSS class name, got '{theme}'" + ) + + def _validate_change_indicator(self, change_indicator: Optional[str]) -> None: + """Validate change_indicator parameter.""" + if change_indicator is not None: + if not isinstance(change_indicator, str): + raise TypeError( + f"Change indicator must be a string, got {type(change_indicator).__name__}" + ) + if len(change_indicator) > 200: + raise ValueError( + f"Change indicator must be 200 characters or less, got {len(change_indicator)} characters" + ) + if not change_indicator.strip(): + raise ValueError("Change indicator cannot be empty or whitespace only") + + @with_default_component_id + @render_safely + def render(self): + data = { + "type": self.type, + "id": self.component_id, + "value": self._value, + } + if self._title is not None: + data["title"] = self._title + if self._subtitle is not None: + data["subtitle"] = self._subtitle + if self._theme is not None: + data["theme"] = self._theme + if self._change_indicator is not None: + data["change_indicator"] = self._change_indicator + return data + + class VegaChart(UserComponent): type = "vegaChart" diff --git a/metaflow/plugins/cards/ui/src/components/card-component-renderer.svelte b/metaflow/plugins/cards/ui/src/components/card-component-renderer.svelte index dbeb3026f83..1705f796045 100644 --- a/metaflow/plugins/cards/ui/src/components/card-component-renderer.svelte +++ b/metaflow/plugins/cards/ui/src/components/card-component-renderer.svelte @@ -14,6 +14,7 @@ import Table from "./table.svelte"; import Text from "./text.svelte"; import Title from "./title.svelte"; + import ValueBox from "./value-box.svelte"; import VegaChart from "./vega-chart.svelte"; import PythonCode from "./python-code.svelte"; @@ -33,6 +34,7 @@ table: Table, text: Text, title: Title, + valueBox: ValueBox, vegaChart: VegaChart, pythonCode: PythonCode, }; diff --git a/metaflow/plugins/cards/ui/src/components/table-data-renderer.svelte b/metaflow/plugins/cards/ui/src/components/table-data-renderer.svelte index d1df599623d..079a180b0f6 100644 --- a/metaflow/plugins/cards/ui/src/components/table-data-renderer.svelte +++ b/metaflow/plugins/cards/ui/src/components/table-data-renderer.svelte @@ -11,6 +11,7 @@ import Markdown from "./markdown.svelte"; import Text from "./text.svelte"; import ProgressBar from "./progress-bar.svelte"; + import ValueBox from "./value-box.svelte"; import PythonCode from "./python-code.svelte"; export let componentData: types.TableDataCell; @@ -25,6 +26,7 @@ markdown: Markdown, progressBar: ProgressBar, text: Text, + valueBox: ValueBox, vegaChart: VegaChart, pythonCode: PythonCode, }; diff --git a/metaflow/plugins/cards/ui/src/components/value-box.svelte b/metaflow/plugins/cards/ui/src/components/value-box.svelte new file mode 100644 index 00000000000..39600719fac --- /dev/null +++ b/metaflow/plugins/cards/ui/src/components/value-box.svelte @@ -0,0 +1,165 @@ + + +
+
+ {#if title} +

{title}

+ {/if} + +
{displayValue}
+ + {#if subtitle} +

{subtitle}

+ {/if} + + {#if change_indicator} +
{change_indicator}
+ {/if} +
+
+ + diff --git a/metaflow/plugins/cards/ui/src/types.ts b/metaflow/plugins/cards/ui/src/types.ts index 8f17c93d710..4a632cd1972 100644 --- a/metaflow/plugins/cards/ui/src/types.ts +++ b/metaflow/plugins/cards/ui/src/types.ts @@ -34,6 +34,7 @@ export type TableDataCell = | MarkdownComponent | ProgressBarComponent | TextComponent + | ValueBoxComponent | VegaChartComponent | PythonCodeComponent; @@ -201,6 +202,16 @@ export interface PythonCodeComponent { data: string; } +export interface ValueBoxComponent { + type: "valueBox"; + id?: string; + title?: string; + value: string | number; + subtitle?: string; + theme?: string; // CSS class for styling + change_indicator?: string; // e.g., "Up 30% VS PREVIOUS 30 DAYS" +} + export interface MarkdownComponent { type: "markdown"; id?: string; @@ -229,5 +240,6 @@ export type CardComponent = | TableComponent | TextComponent | TitleComponent + | ValueBoxComponent | VegaChartComponent | PythonCodeComponent; From 5da85474c40b28b80843290943004997bb682f32 Mon Sep 17 00:00:00 2001 From: Valay Dave Date: Fri, 19 Sep 2025 10:29:26 -0700 Subject: [PATCH 2/2] [gh action] to build card bundle. --- .github/workflows/build-ui-assets.yml | 151 ++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/build-ui-assets.yml diff --git a/.github/workflows/build-ui-assets.yml b/.github/workflows/build-ui-assets.yml new file mode 100644 index 00000000000..45b44c0d82f --- /dev/null +++ b/.github/workflows/build-ui-assets.yml @@ -0,0 +1,151 @@ +name: Build UI Assets and Create PR + +on: + workflow_dispatch: + +jobs: + build-and-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: metaflow/plugins/cards/ui/package-lock.json + + - name: Install dependencies + working-directory: ./metaflow/plugins/cards/ui + run: npm ci + + - name: Lint and type check + working-directory: ./metaflow/plugins/cards/ui + run: | + npm run check + npm run lint + + - name: Clean previous build artifacts + run: | + # Remove previous build artifacts to ensure clean build + rm -f metaflow/plugins/cards/card_modules/main.js + rm -f metaflow/plugins/cards/card_modules/bundle.css + rm -f metaflow/plugins/cards/card_modules/main.js.map + + - name: Build UI assets + working-directory: ./metaflow/plugins/cards/ui + run: npm run build + + - name: Verify build outputs + run: | + # Check that the expected files were created + if [[ ! -f "metaflow/plugins/cards/card_modules/main.js" ]]; then + echo "::error::main.js was not created by the build process" + exit 1 + fi + + if [[ ! -f "metaflow/plugins/cards/card_modules/bundle.css" ]]; then + echo "::error::bundle.css was not created by the build process" + exit 1 + fi + + # Check file sizes (they should not be empty) + main_size=$(stat -c%s "metaflow/plugins/cards/card_modules/main.js") + css_size=$(stat -c%s "metaflow/plugins/cards/card_modules/bundle.css") + + if [[ $main_size -lt 1000 ]]; then + echo "::error::main.js seems too small ($main_size bytes), build may have failed" + exit 1 + fi + + if [[ $css_size -lt 500 ]]; then + echo "::error::bundle.css seems too small ($css_size bytes), build may have failed" + exit 1 + fi + + echo "✅ Build outputs verified:" + echo " main.js: $main_size bytes" + echo " bundle.css: $css_size bytes" + + - name: Check for changes + id: changes + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + # Check if there are changes to the built files + if git diff --quiet metaflow/plugins/cards/card_modules/main.js metaflow/plugins/cards/card_modules/bundle.css; then + echo "No changes detected in built files" + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "Changes detected in built files" + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore: update UI build artifacts (main.js, bundle.css)" + title: "🔧 Update UI Build Artifacts" + body: | + ## UI Build Artifacts Update + + This PR updates the built UI assets (`main.js` and `bundle.css`) from the latest changes in the UI source code. + + ### Changes + - 📦 Updated `metaflow/plugins/cards/card_modules/main.js` + - 🎨 Updated `metaflow/plugins/cards/card_modules/bundle.css` + + ### Build Info + - **Triggered by**: @${{ github.actor }} + - **Source branch**: `${{ github.ref_name }}` + - **Build timestamp**: ${{ steps.build-info.outputs.timestamp }} + - **Node.js version**: 20.x + + ### Verification + These files were built using: + ```bash + cd metaflow/plugins/cards/ui + npm ci + npm run build + ``` + + The build process includes: + - TypeScript checking (`npm run check`) + - ESLint validation (`npm run lint`) + - Vite build with minification + + --- + *This PR was automatically created by the Build UI Assets workflow.* + branch: build/ui-assets-${{ github.run_number }} + base: ${{ github.ref_name }} + delete-branch: true + draft: false + add-paths: | + metaflow/plugins/cards/card_modules/main.js + metaflow/plugins/cards/card_modules/bundle.css + metaflow/plugins/cards/card_modules/main.js.map + + - name: Add build timestamp + id: build-info + run: echo "timestamp=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT + + - name: No changes message + if: steps.changes.outputs.has_changes == 'false' + run: | + echo "::notice::No changes detected in the built UI assets. The main.js and bundle.css files are already up to date." + + - name: Success message + if: steps.changes.outputs.has_changes == 'true' + run: | + echo "::notice::Successfully created PR with updated UI build artifacts!"