Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/build-ui-assets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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

- 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!"
1 change: 1 addition & 0 deletions metaflow/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Markdown,
VegaChart,
ProgressBar,
ValueBox,
PythonCode,
)
from metaflow.plugins.cards.card_modules.basic import (
Expand Down
2 changes: 1 addition & 1 deletion metaflow/plugins/cards/card_modules/bundle.css

Large diffs are not rendered by default.

228 changes: 228 additions & 0 deletions metaflow/plugins/cards/card_modules/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
56 changes: 28 additions & 28 deletions metaflow/plugins/cards/card_modules/main.js

Large diffs are not rendered by default.

Loading