diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 00000000..6bce7836 --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,82 @@ +# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +name: Pullfrog +run-name: ${{ inputs.name || github.workflow }} +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: Agent prompt + name: + type: string + description: Run name + +permissions: + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/pullfrog@v0 + with: + prompt: ${{ inputs.prompt }} + # Fireworks GLM 5.3 — same model roborev uses in gtm-sdk + # (.roborev.toml: fireworks/accounts/fireworks/models/glm-5p3). + # Raw models.dev specifier; runs through OpenCode on BYOK. + model: fireworks-ai/accounts/fireworks/models/glm-5p3 + env: + # add at least one provider API key + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: + ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) + # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # AWS_REGION: us-east-1 + # BEDROCK_MODEL_ID: + + # for Google Vertex AI (https://docs.pullfrog.com/vertex) + # VERTEX_SERVICE_ACCOUNT_JSON: >- + # ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} + # GOOGLE_CLOUD_PROJECT: my-project + # VERTEX_LOCATION: global + # VERTEX_MODEL_ID: + + # for Azure OpenAI (https://docs.pullfrog.com/azure) + # AZURE_RESOURCE_NAME is the in https://.openai.azure.com + # AZURE_DEPLOYMENT is your deployment's name, not the model it serves + # AZURE_RESOURCE_NAME: my-resource + # AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }} + # AZURE_DEPLOYMENT: + # both limits are required — set them to the real limits of that model + # AZURE_CONTEXT: "400000" + # AZURE_MAX_OUTPUT: "128000" + + # for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway, + # self-hosted vLLM (https://docs.pullfrog.com/openai-compatible) + # OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1 + # OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }} + # OPENAI_COMPATIBLE_MODEL: + # both limits are required — set them to the real limits of that model + # OPENAI_COMPATIBLE_CONTEXT: "128000" + # OPENAI_COMPATIBLE_MAX_OUTPUT: "16384" diff --git a/.gitignore b/.gitignore index bc4fa791..f108e2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,9 @@ wheels/ *.egg-info codebook.toml +# Local secrets (used by scripts/linear_cli.py via gtm-linear) — never commit +.env +.env.local + # Virtual environments .venv diff --git a/.roborev.toml b/.roborev.toml new file mode 100644 index 00000000..0d396b03 --- /dev/null +++ b/.roborev.toml @@ -0,0 +1,8 @@ +# roborev review configuration for buridan/ui. +# +# `review_guidelines` is embedded into every review prompt. Use it to encode +# deliberate house decisions so the reviewer stops re-flagging them as findings. + +# Use the Pi coding agent with the Fireworks provider for review jobs. +agent = "pi" +model = "fireworks/accounts/fireworks/models/glm-5p3" diff --git a/app/engine/actions.py b/app/engine/actions.py index c1e467da..3d135c63 100644 --- a/app/engine/actions.py +++ b/app/engine/actions.py @@ -165,17 +165,6 @@ def _sync_sidebar_js() -> str: return "if (window.__syncSidebar) window.__syncSidebar(config);" -# ── helpers used by multiple actions ───────────────────────────────────────── - - -def _set_refs(seed_val: str = "newSeed") -> str: - """Sync ClientStateVars that are still needed for reactive UI.""" - return f""" - // Still needed: seed display, copy button, css_output rendering - if (refs['_client_state_setSeed']) refs['_client_state_setSeed']({seed_val}); - """ - - # ── actions ─────────────────────────────────────────────────────────────────── SHUFFLE_JS = f""" diff --git a/app/engine/seed.py b/app/engine/seed.py index 39d350c1..fbb86401 100644 --- a/app/engine/seed.py +++ b/app/engine/seed.py @@ -52,15 +52,6 @@ def seed_engine() -> rx.Script: return out; }} - function toBase62(n, len) {{ - let res = ""; - for (let i = 0; i < len; i++) {{ - res += CHARS[n % 62]; - n = Math.floor(n / 62); - }} - return res; - }} - function fromBase62(s) {{ let n = 0; for (let i = s.length - 1; i >= 0; i--) {{ @@ -69,27 +60,6 @@ def seed_engine() -> rx.Script: return n; }} - function encodeConfig(theme) {{ - const bIdx = BASE_THEMES.findIndex(b => b.id === theme['__base_id']); - const cIdx = theme['__color_id'] ? COLOR_THEMES.findIndex(c => c.id === theme['__color_id']) + 1 : 0; - const chIdx = theme['__chart_id'] ? COLOR_THEMES.findIndex(c => c.id === theme['__chart_id']) + 1 : 0; - const sIdx = STYLE_REGISTRY.findIndex(s => s.id === theme['__style_id']); - const fIdx = FONT_REGISTRY.findIndex(f => f.id === theme['__font_id']); - const rIdx = RADIUS_OPTIONS.findIndex(r => r[1] === theme['--radius']); - - if (bIdx === -1 || sIdx === -1 || fIdx === -1 || rIdx === -1) return null; - - // Default state special case - if (bIdx === 0 && cIdx === 0 && chIdx === 0 && sIdx === 0 && fIdx === 0 && rIdx === 2) return "b0"; - - // State space: 6 * 11 * 11 * 5 * 5 * 4 = 72600 - let n = (((((bIdx * 11 + cIdx) * 11 + chIdx) * 5 + sIdx) * 5 + fIdx) * 4 + rIdx); - - // Encode as 9 chars: [Base62(N, 4)] + [Base62(Checksum(N), 5)] - const checksum = (n * 12345) % 916132832; // 62^5 - return toBase62(n, 4) + toBase62(checksum, 5); - }} - function decodeSeed(seed) {{ if (!seed) return null; if (seed === "b0") {{ @@ -210,12 +180,6 @@ def seed_engine() -> rx.Script: return theme; }} - function randomSeed() {{ - const n = Math.floor(Math.random() * 72600); - const checksum = (n * 12345) % 916132832; - return toBase62(n, 4) + toBase62(checksum, 5); - }} - window.__STYLE_REGISTRY = STYLE_REGISTRY; window.__BASE_THEMES = BASE_THEMES; window.__COLOR_THEMES = COLOR_THEMES; @@ -223,8 +187,6 @@ def seed_engine() -> rx.Script: window.__RADIUS_OPTIONS = RADIUS_OPTIONS; window.__generateFromSeed = generateFromSeed; window.__rebuildTheme = rebuildTheme; - window.__randomSeed = randomSeed; window.__flattenVars = flattenVars; - window.__encodeConfig = encodeConfig; window.__decodeSeed = decodeSeed; """) diff --git a/app/examples/components.py b/app/examples/components.py index b7abc6f1..5632520a 100644 --- a/app/examples/components.py +++ b/app/examples/components.py @@ -741,7 +741,6 @@ def card_ten() -> rx.Component: ), input_group.root( input_group.input( - id="inline-start-input", placeholder=field["placeholder"], ), input_group.addon( @@ -1143,13 +1142,13 @@ def checkbox_row(item: dict) -> rx.Component: @masonry_card(label="Healthcare") def card_fourteen() -> rx.Component: days = [ - {"label": "M", "calories": 75, "load": 60}, - {"label": "T", "calories": 30, "load": 25}, - {"label": "W", "calories": 55, "load": 45}, - {"label": "T", "calories": 45, "load": 35}, - {"label": "F", "calories": 80, "load": 65}, - {"label": "S", "calories": 25, "load": 20}, - {"label": "S", "calories": 40, "load": 30}, + {"label": "M", "calories": 75}, + {"label": "T", "calories": 30}, + {"label": "W", "calories": 55}, + {"label": "T", "calories": 45}, + {"label": "F", "calories": 80}, + {"label": "S", "calories": 25}, + {"label": "S", "calories": 40}, ] def day_bar(day: dict) -> rx.Component: diff --git a/app/examples/utils.py b/app/examples/utils.py index 918a597b..5ccd986a 100644 --- a/app/examples/utils.py +++ b/app/examples/utils.py @@ -57,23 +57,24 @@ def wrapper(*args, **kwargs): inner_component = func(*args, **kwargs) cmd = f"uv run buridan add {func.__qualname__}" btn_id = generate_component_id() + text_id = f"text-{btn_id}" return rx.el.div( rx.el.div( button( hi("TerminalIcon", class_name="size-4 shrink-0"), - cmd, + rx.el.span(cmd, id=text_id), variant="outline", size="sm", id=btn_id, on_click=rx.call_script(f""" - const btn = document.getElementById({btn_id!r}); + const text = document.getElementById({text_id!r}); navigator.clipboard.writeText({cmd!r}); - const original = btn.innerText; - btn.innerText = "Copied!"; + const original = text.innerText; + text.innerText = "Copied!"; setTimeout(() => {{ - btn.innerText = original; + text.innerText = original; }}, 1000); """), class_name="min-w-3xs", diff --git a/app/templates/compiler.py b/app/templates/compiler.py index d43e49d9..87b49118 100644 --- a/app/templates/compiler.py +++ b/app/templates/compiler.py @@ -41,49 +41,6 @@ """ -def select_radio_option_row( - title: str, description: str, group_name: str, value: str, default_on: bool = False -) -> rx.Component: - return rx.el.label( - rx.el.div( - rx.el.p(title, class_name="text-foreground font-medium text-sm"), - rx.el.p( - description, - class_name="text-muted-foreground max-w-[24rem] text-xs font-light leading-normal", - ), - class_name="flex flex-col gap-y-1", - ), - rx.el.div( - rx.el.input( - type="radio", - name=group_name, - value=value, - default_checked=default_on, - class_name="peer sr-only", - ), - rx.el.div( - rx.el.span( - "✓", - class_name="text-primary-foreground text-[11px] font-bold hidden", - ), - class_name=( - "flex size-4 items-center justify-center rounded-[4px] transition-colors " - "border border-input bg-transparent " - "peer-checked:bg-primary peer-checked:border-primary " - "[&_span]:peer-checked:block" - ), - ), - class_name="flex items-center", - ), - class_name=( - "w-full rounded-2xl px-4 py-2.5 flex flex-row items-center justify-between " - "text-left transition-all cursor-pointer border-1 " - "border-input/90 bg-transparent hover:bg-secondary/20 " - "has-[:checked]:border-primary has-[:checked]:dark:border-input has-[:checked]:bg-secondary/60" - ), - ) - - def select_local_or_reflex_build_option( title: str, description: str, diff --git a/app/www/library/charts/bar/v8.py b/app/www/library/charts/bar/v8.py deleted file mode 100644 index ee05fdfe..00000000 --- a/app/www/library/charts/bar/v8.py +++ /dev/null @@ -1,134 +0,0 @@ -import reflex as rx - -from components.charts.chart_tooltip import chart_tooltip, chart_tooltip_content -from components.ui.card import card - -categories = ["Successful", "Refunded"] -EUROPE = [ - {"date": f"{month} 23", "Successful": successful, "Refunded": refunded} - for month, successful, refunded in zip( - [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ], - [12, 24, 48, 24, 34, 26, 12, 38, 23, 20, 24, 21], - [0, 1, 4, 2, 0, 0, 0, 2, 1, 0, 0, 8], - ) -] - -ASIA = [ - {"date": f"{month} 23", "Successful": successful, "Refunded": refunded} - for month, successful, refunded in zip( - [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ], - [31, 32, 44, 23, 35, 48, 33, 38, 41, 39, 32, 19], - [1, 2, 3, 2, 1, 1, 1, 3, 2, 1, 1, 5], - ) -] - - -def create_chart(data: list[dict[str, str | int]]): - return rx.recharts.bar_chart( - chart_tooltip(), - rx.recharts.cartesian_grid( - horizontal=True, vertical=False, class_name="opacity-30" - ), - rx.foreach( - categories, - lambda key, index: rx.recharts.bar( - data_key=key, - fill=f"var(--chart-{index + 1})", - stack_id="_", - is_animation_active=False, - ), - ), - rx.recharts.x_axis( - data_key="date", - axis_line=False, - tick_size=10, - tick_line=False, - custom_attrs={"fontSize": "12px"}, - interval=2, - ), - data=data, - width="100%", - height=250, - ) - - -def barchart_v8(): - - return card.root( - card.header( - rx.hstack( - rx.el.div( - card.title("Online Transactions"), - card.description("Global revenue distributions"), - class_name="flex flex-col gap-y-1.5", - ), - rx.tabs.root( - rx.tabs.list( - rx.tabs.trigger( - rx.text("Europe", class_name="text-xs font-semibold"), - value="1", - ), - rx.tabs.trigger( - rx.text("Asia", class_name="text-xs font-semibold"), - value="2", - ), - ), - default_value="1", - id="transactions-tabs", - ), - align="center", - justify="between", - width="100%", - ), - ), - card.content( - rx.tabs.root( - rx.tabs.content(create_chart(EUROPE), value="1"), - rx.tabs.content(create_chart(ASIA), value="2"), - default_value="1", - ), - ), - card.footer( - rx.el.div( - rx.el.div( - rx.el.div( - "Trending up by 5.2% this month ", - class_name="flex items-center gap-2 leading-none font-medium", - ), - rx.el.div( - "January - June 2024", - class_name="flex items-center gap-2 leading-none text-muted-foreground", - ), - class_name="grid gap-2", - ), - class_name="flex w-full items-start gap-2 text-sm", - ) - ), - class_name=chart_tooltip_content([1, 2], "square") + " w-full p-0", - ) diff --git a/app/www/library/getting_started/charting_components.py b/app/www/library/getting_started/charting_components.py deleted file mode 100644 index cf67d234..00000000 --- a/app/www/library/getting_started/charting_components.py +++ /dev/null @@ -1,81 +0,0 @@ -import reflex as rx - - -# Used in --show_code(charting_state_example)-- -class charting_state_example(rx.State): - data = [ - {"month": "Jan", "desktop": 186}, - {"month": "Feb", "desktop": 305}, - {"month": "Mar", "desktop": 237}, - {"month": "Apr", "desktop": 73}, - {"month": "May", "desktop": 209}, - {"month": "Jun", "desktop": 214}, - ] - - -# Used in --show_code(area_chart_example)-- -def area_chart_example(): - return rx.recharts.area_chart( - rx.recharts.area( - data_key="desktop", - fill=rx.color("accent"), - stroke=rx.color("accent", 8), - ), - data=charting_state_example.data, - width="100%", - height=250, - ) - - -# Used in --show_code(cartesian_grid_example)-- -def cartesian_grid_example(): - return rx.recharts.cartesian_grid( - horizontal=True, vertical=False, class_name="opacity-25" - ) - - -# Used in --show_code(xaxis_example)-- -def xaxis_example(): - return rx.recharts.x_axis( - data_key="month", - axis_line=False, - tick_size=10, - tick_line=False, - custom_attrs={"fontSize": "12px"}, - interval="preserveStartEnd", - ) - - -# Used in --show_code(tooltip_example)-- -def tooltip_example(): - tooltip_styles = { - "is_animation_active": False, - "separator": "", - "cursor": False, - "item_style": { - "color": "currentColor", - "display": "flex", - "paddingBottom": "0px", - "justifyContent": "space-between", - "textTransform": "capitalize", - }, - "label_style": { - "color": rx.color("slate", 9), - "fontWeight": "500", - }, - "content_style": { - "background": rx.color("slate", 1), - "borderColor": rx.color("slate", 5), - "borderRadius": "5px", - "fontFamily": "var(--font-instrument-sans)", - "fontSize": "0.875rem", - "lineHeight": "1.25rem", - "fontWeight": "500", - "letterSpacing": "-0.01rem", - "minWidth": "8rem", - "width": "175px", - "padding": "0.375rem 0.625rem", - "position": "relative", - }, - } - return rx.recharts.tooltip(**tooltip_styles) diff --git a/app/www/library/getting_started/clientstate_components.py b/app/www/library/getting_started/clientstate_components.py index c8f8d818..c3c35a53 100644 --- a/app/www/library/getting_started/clientstate_components.py +++ b/app/www/library/getting_started/clientstate_components.py @@ -1,21 +1,5 @@ import reflex as rx from reflex.experimental import ClientStateVar -from dataclasses import dataclass # Added for Session class placeholder - - -# Placeholder for Session class used in Complex Example -@dataclass -class Session: - session_id: str - name: str - blocks: list # Assuming blocks is a list, adjust as needed - - def copy(self, update: dict): - # Simple copy for demonstration - new_session = Session(self.session_id, self.name, self.blocks) - if "name" in update: - new_session.name = update["name"] - return new_session # Used in --show_code(tab_navigation_example)-- diff --git a/app/www/library/getting_started/dashboard_components.py b/app/www/library/getting_started/dashboard_components.py deleted file mode 100644 index de1a6bca..00000000 --- a/app/www/library/getting_started/dashboard_components.py +++ /dev/null @@ -1,112 +0,0 @@ -import reflex as rx - - -# Used in --show_code(project_setup_init_example)-- -def project_setup_init_example(): - return rx.markdown("```bash\nreflex init --name app\n```") - - -# Used in --show_code(project_setup_run_example)-- -def project_setup_run_example(): - return rx.markdown("```bash\nreflex run\n```") - - -# Used in --show_code(dashboard_layout_example)-- -def dashboard_layout_example(): - # Assuming app.layout.sidebar and app.layout.content_header are defined elsewhere - # For demonstration, we'll use placeholders - def sidebar(): - return rx.box("Sidebar") - - def content_header(): - return rx.box("Content Header") - - return rx.box( - sidebar(), - rx.scroll_area( - content_header(), - class_name=( - "flex flex-col w-full gap-y-2 align-start z-[10] pt-12 " - "[&_.rt-ScrollAreaScrollbar]:mt-[4rem] " - "[&_.rt-ScrollAreaScrollbar]:mb-[1rem]" - ), - height=["100%" if i == 0 else "100vh" for i in range(6)], - ), - class_name="w-[100%] h-[100vh] gap-x-0 flex flex-row", - ) - - -# Used in --show_code(app_py_example)-- -def app_py_example(): - return rx.markdown( - r"""``` -# app/app.py - -import reflex as rx -from app.layout.layout import dashboard_layout - -def index() -> rx.Component: - return dashboard_layout() - -app = rx.App() -app.add_page(index) -```""" - ) - - -# Used in --show_code(sidebar_example)-- -def sidebar_example(): - # Assuming sidebar_header is defined elsewhere - def sidebar_header(): - return rx.box("Sidebar Header") - - return rx.scroll_area( - sidebar_header(), - rx.box( - # Sidebar content (e.g., nav links) goes here - class_name="flex flex-col w-full h-full pt-12 px-2", - ), - class_name=( - "flex flex-col max-w-[300px] w-full h-[100vh] gap-y-2 align-start " - "sticky top-0 left-0 z-[10] " - "[&_.rt-ScrollAreaScrollbar]:mr-[0.1875rem] " - "[&_.rt-ScrollAreaScrollbar]:mt-[4rem] " - "[&_.rt-ScrollAreaScrollbar]:mb-[1rem]" - ), - ) - - -# Used in --show_code(sidebar_header_example)-- -def sidebar_header_example(): - return rx.box( - # You can add a logo, app title, or user info here - class_name="w-full h-12 p-2 absolute top-0 left-0 z-[99]", - ) - - -# Used in --show_code(content_header_example)-- -def content_header_example(): - return rx.box( - # You can add breadcrumbs, titles, or action buttons here - class_name="w-full h-12 p-2 absolute top-0 left-0 z-[99]", - ) - - -# Used in --show_code(main_content_section_example)-- -def main_content_section_example(): - # Assuming content_header, my_chart_component, my_table_component are defined elsewhere - def content_header(): - return rx.box("Content Header") - - def my_chart_component(): - return rx.box("My Chart Component") - - def my_table_component(): - return rx.box("My Table Component") - - return rx.scroll_area( - content_header(), - my_chart_component(), - my_table_component(), - # ... - ) diff --git a/app/www/library/getting_started/theming_components.py b/app/www/library/getting_started/theming_components.py deleted file mode 100644 index f673181e..00000000 --- a/app/www/library/getting_started/theming_components.py +++ /dev/null @@ -1,68 +0,0 @@ -import reflex as rx - - -# Used in --show_code(app_stylesheets_example)-- -def app_stylesheets_example(): - return rx.markdown( - """```python -import reflex as rx - -app = rx.App(stylesheets=["/theme.css"]) -```""" - ) - - -# Used in --show_code(fill_prop_example)-- -def fill_prop_example(): - return rx.markdown( - """```python -fill="var(--chart-1)" -```""" - ) - - -# Used in --show_code(theme_red_example)-- -def theme_red_example(): - def get_tooltip(): - return rx.box("Tooltip") - - def get_cartesian_grid(): - return rx.box("Cartesian Grid") - - def get_x_axis(label): - return rx.box(f"X-Axis: {label}") - - data = [] - - return rx.box( - # ... - rx.recharts.area_chart( - get_tooltip(), - get_cartesian_grid(), - rx.recharts.area( - data_key="desktop", - fill="var(--chart-1)", - stroke="var(--chart-1)", - stroke_width=2, - ), - get_x_axis("month"), - data=data, - width="100%", - height=250, - ), - # ... - class_name="theme-red", # <- .theme-red from theme.css - ) - - -# Used in --show_code(custom_theme_css_example)-- -def custom_theme_css_example(): - return rx.markdown( - """```css -.theme-coral { - --chart-1: oklch(0.82 0.18 40); - --chart-2: oklch(0.7 0.19 38); - ... -} -```""" - ) diff --git a/app/www/wrapper.py b/app/www/wrapper.py index df60a18b..6890758e 100644 --- a/app/www/wrapper.py +++ b/app/www/wrapper.py @@ -94,7 +94,7 @@ def file_codeblock(file_path: str, source: str) -> rx.Component: id=f"code-panel-{toggle_height_id}", class_name="scrollbar-none flex-1 min-h-0 flex flex-col h-full relative overflow-x-auto", ), - class_name="rounded-lg outline outline-input flex-1 min-h-0 flex flex-col bg-secondary dark:bg-card", + class_name="rounded-lg outline outline-input flex-1 min-h-0 flex flex-col bg-secondary dark:bg-card relative", ) @@ -194,7 +194,7 @@ def chart_util_wrapper(source: str): ), class_name="bg-secondary dark:bg-card relative overflow-hidden", ), - class_name="w-full border border-input rounded-lg mb-8 !overflow-hidden", + class_name="w-full border border-input rounded-lg mb-8 !overflow-hidden relative", ), ) diff --git a/assets/docs/components/collapsible.md b/assets/docs/components/collapsible.md index 295308fd..8ab6456d 100644 --- a/assets/docs/components/collapsible.md +++ b/assets/docs/components/collapsible.md @@ -17,18 +17,20 @@ buridan add component collapsible ### Manual Installation ```python +import reflex as rx from reflex.components.component import Component, ComponentNamespace from reflex.event import EventHandler, passthrough_event_spec from reflex.utils.imports import ImportVar from reflex.vars.base import Var -from .core import PACKAGE_NAME, BaseUIComponent +from .core import PACKAGE_NAME, BaseUIComponent, cn class ClassNames: ROOT = "flex flex-col justify-center text-secondary-12" TRIGGER = "group flex items-center gap-2" - PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL_DIV = "" class CollapsibleBaseComponent(BaseUIComponent): @@ -78,8 +80,12 @@ class CollapsiblePanel(CollapsibleBaseComponent): @classmethod def create(cls, *children, **props) -> BaseUIComponent: props["data-slot"] = "collapsible-panel" + inner_class = props.pop("class_name", "") cls.set_class_name(ClassNames.PANEL, props) - return super().create(*children, **props) + return super().create( + rx.el.div(*children, class_name=cn(ClassNames.PANEL_DIV, inner_class)), + **props, + ) class Collapsible(ComponentNamespace): diff --git a/assets/docs/components/menu.md b/assets/docs/components/menu.md index dc8cfb06..bb24e57b 100644 --- a/assets/docs/components/menu.md +++ b/assets/docs/components/menu.md @@ -28,23 +28,11 @@ from reflex_components_core.el import Span from ..icons.hugeicon import hi from .core import PACKAGE_NAME, BaseUIComponent, CoreComponent -LiteralOpenChangeReason = Literal[ - "arrowKey", - "escapeKey", - "select", - "hover", - "click", - "focus", - "dismiss", - "typeahead", - "tab", -] LiteralMenuOrientation = Literal["vertical", "horizontal"] LiteralSide = Literal["top", "right", "bottom", "left"] LiteralAlign = Literal["start", "center", "end"] LiteralPositionMethod = Literal["absolute", "fixed"] LiteralCollisionAvoidance = Literal["flip", "shift", "auto"] -LiteralMenuSize = Literal["xs", "sm", "md", "lg", "xl"] class ClassNames: @@ -78,18 +66,6 @@ class ClassNames: "[&_svg:not([class*='size-'])]:size-4" ) SEPARATOR = "-mx-1 my-1 h-px bg-border" - POSITIONER_SUB = "isolate z-50 outline-none" - POPUP_SUB = ( - "z-50 w-auto min-w-24 origin-(--transform-origin) overflow-x-hidden " - "overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground " - "shadow-lg ring-1 ring-foreground/10 duration-100 " - "data-[side=bottom]:slide-in-from-top-2 " - "data-[side=left]:slide-in-from-right-2 " - "data-[side=right]:slide-in-from-left-2 " - "data-[side=top]:slide-in-from-bottom-2 " - "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 " - "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" - ) GROUP_LABEL = ( "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7" ) @@ -136,7 +112,6 @@ class ClassNames: GROUP = "" RADIO_GROUP = "" ITEM_TEXT = "text-start" - ITEM_INDICATOR = "text-current" SHORTCUT = ( "ml-auto text-xs tracking-widest text-muted-foreground " "group-focus/menu-item:text-accent-foreground" diff --git a/components/ui/button_group.py b/components/ui/button_group.py index 4896abfb..74963d05 100644 --- a/components/ui/button_group.py +++ b/components/ui/button_group.py @@ -13,7 +13,7 @@ class ClassNames: HORIZONTAL = "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0" VERTICAL = "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0" TEXT = "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4" - SEPARATOR = "relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto" + SEPARATOR = "relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto" class ButtonGroupRoot(Div, CoreComponent): diff --git a/components/ui/collapsible.py b/components/ui/collapsible.py index 54542493..ecb11c88 100644 --- a/components/ui/collapsible.py +++ b/components/ui/collapsible.py @@ -1,15 +1,17 @@ +import reflex as rx from reflex.components.component import Component, ComponentNamespace from reflex.event import EventHandler, passthrough_event_spec from reflex.utils.imports import ImportVar from reflex.vars.base import Var -from .core import PACKAGE_NAME, BaseUIComponent +from .core import PACKAGE_NAME, BaseUIComponent, cn class ClassNames: ROOT = "flex flex-col justify-center text-secondary-12" TRIGGER = "group flex items-center gap-2" - PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL_DIV = "" class CollapsibleBaseComponent(BaseUIComponent): @@ -59,8 +61,12 @@ class CollapsiblePanel(CollapsibleBaseComponent): @classmethod def create(cls, *children, **props) -> BaseUIComponent: props["data-slot"] = "collapsible-panel" + inner_class = props.pop("class_name", "") cls.set_class_name(ClassNames.PANEL, props) - return super().create(*children, **props) + return super().create( + rx.el.div(*children, class_name=cn(ClassNames.PANEL_DIV, inner_class)), + **props, + ) class Collapsible(ComponentNamespace): diff --git a/components/ui/context_menu.py b/components/ui/context_menu.py index 51444d64..b38f8dda 100644 --- a/components/ui/context_menu.py +++ b/components/ui/context_menu.py @@ -344,7 +344,7 @@ class ContextMenuShortcut(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: props["data-slot"] = "context-menu-shortcut" - props["class_name"] = ClassNames.SHORTCUT + cls.set_class_name(ClassNames.SHORTCUT, props) return super().create(*children, **props) diff --git a/components/ui/menu.py b/components/ui/menu.py index 3e386f49..0b2ecf3a 100644 --- a/components/ui/menu.py +++ b/components/ui/menu.py @@ -9,23 +9,11 @@ from ..icons.hugeicon import hi from .core import PACKAGE_NAME, BaseUIComponent, CoreComponent -LiteralOpenChangeReason = Literal[ - "arrowKey", - "escapeKey", - "select", - "hover", - "click", - "focus", - "dismiss", - "typeahead", - "tab", -] LiteralMenuOrientation = Literal["vertical", "horizontal"] LiteralSide = Literal["top", "right", "bottom", "left"] LiteralAlign = Literal["start", "center", "end"] LiteralPositionMethod = Literal["absolute", "fixed"] LiteralCollisionAvoidance = Literal["flip", "shift", "auto"] -LiteralMenuSize = Literal["xs", "sm", "md", "lg", "xl"] class ClassNames: @@ -59,18 +47,6 @@ class ClassNames: "[&_svg:not([class*='size-'])]:size-4" ) SEPARATOR = "-mx-1 my-1 h-px bg-border" - POSITIONER_SUB = "isolate z-50 outline-none" - POPUP_SUB = ( - "z-50 w-auto min-w-24 origin-(--transform-origin) overflow-x-hidden " - "overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground " - "shadow-lg ring-1 ring-foreground/10 duration-100 " - "data-[side=bottom]:slide-in-from-top-2 " - "data-[side=left]:slide-in-from-right-2 " - "data-[side=right]:slide-in-from-left-2 " - "data-[side=top]:slide-in-from-bottom-2 " - "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 " - "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" - ) GROUP_LABEL = ( "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7" ) @@ -117,7 +93,6 @@ class ClassNames: GROUP = "" RADIO_GROUP = "" ITEM_TEXT = "text-start" - ITEM_INDICATOR = "text-current" SHORTCUT = ( "ml-auto text-xs tracking-widest text-muted-foreground " "group-focus/menu-item:text-accent-foreground" diff --git a/pyproject.toml b/pyproject.toml index 42b220ac..110bda30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ dev = [ "PyYAML>=6.0.3", "questionary>=2.1.1", "pytest>=9.1.1", + "gtm-linear>=0.2.1", + "typer>=0.27.2", ] [tool.uv] diff --git a/scripts/linear_cli.py b/scripts/linear_cli.py new file mode 100755 index 00000000..ff934a97 --- /dev/null +++ b/scripts/linear_cli.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Read-only Linear CLI built on the gtm-linear SDK, powered by Typer. + +The gtm-linear package (github.com/elviskahoro/sdk-python-linear) is a Python +SDK and deliberately ships no ``linear`` executable, so this thin wrapper +exposes its ``LinearWorkflow`` facade as shell commands for fetching Linear +issues from this workspace. + +Auth: ``LINEAR_API_KEY`` (``lin_api_...``) resolved from the environment or a +``.env`` / ``.env.local`` file in the working directory — the same resolution +``LinearClient.from_env()`` performs. The workspace-root ``.env.local`` holds +the key and is gitignored. + +Usage (from the workspace root; gtm-linear and typer are dev dependencies, so +``uv run`` installs them automatically): + + uv run scripts/linear_cli.py viewer + uv run scripts/linear_cli.py teams + uv run scripts/linear_cli.py issues --team ENG [--state all] [--limit 25] + uv run scripts/linear_cli.py issue ENG-123 + uv run scripts/linear_cli.py search "onboarding" [--limit 10] + +Append ``--json`` to any command for machine-readable output. All commands are +read-only; for writes use the SDK directly (``gtm_linear.LinearMutations``). +Options are validated at parse time: ``--limit`` accepts 1-100 and out-of-range +values exit with a usage error (code 2) rather than being clamped. +""" + +from __future__ import annotations + +import enum +import json +from typing import Any, NoReturn + +import typer +from pydantic import ValidationError + +from gtm_linear import ( + LinearAPIError, + LinearClient, + LinearWorkflow, + PaginationOrderBy, +) + +app = typer.Typer( + help="Fetch Linear issues via the gtm-linear SDK (read-only).", + no_args_is_help=True, + # This is a non-interactive script: unexpected exceptions must print a + # plain traceback, never Typer's default Rich traceback with local + # variables — those frames hold the LinearClient and its API key. + pretty_exceptions_show_locals=False, + pretty_exceptions_enable=False, +) + +# Linear's page-size ceiling for issue connections; larger --limit values are +# clamped rather than letting the API reject them. +MAX_PAGE_SIZE = 100 + +# Linear's numeric priority convention: 0 = no priority set. The API sends +# priorities as floats (2.0 == High), so normalize before the label lookup. +PRIORITY_LABELS = {1: "Urgent", 2: "High", 3: "Medium", 4: "Low"} + + +class State(enum.Enum): + """Which issues a team listing includes.""" + + open = "open" + all = "all" + + +def _fail(message: str) -> NoReturn: + """Print an error to stderr and exit 1 — one style for every failure path.""" + typer.secho(f"error: {message}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + +# --- helpers (logic kept separate from the thin Typer command functions) --- + + +def _workflow() -> LinearWorkflow: + """Build a LinearWorkflow from env/dotenv auth, exiting with help on failure.""" + try: + client = LinearClient.from_env() + except ValidationError: + _fail( + "no LINEAR_API_KEY found. Set it in the environment or in a " + ".env / .env.local file in the working directory (lin_api_... key).", + ) + return LinearWorkflow(client.api_key) + + +def _issue_dict(issue: Any) -> dict[str, Any]: + """Flatten one issue into the dict shape used by --json output.""" + return { + "identifier": issue.identifier, + "title": issue.title, + "description": issue.description, + "url": issue.url, + "state": issue.state.name if issue.state else None, + "priority": issue.priority, + "assignee": issue.assignee.name if issue.assignee else None, + "id": issue.id, + } + + +def _priority_label(priority: float | None) -> str: + if priority is None: + return "-" + level = int(priority) + if level == 0: + return "-" + return PRIORITY_LABELS.get(level, str(level)) + + +def _print_issues(issues: list[Any], *, as_json: bool, verbose: bool = False) -> None: + """Render an issue list as JSON or an aligned table.""" + if as_json: + typer.echo(json.dumps([_issue_dict(i) for i in issues], indent=2)) + return + + if not issues: + typer.echo("no issues found") + return + + columns = ("IDENTIFIER", "STATE", "PRIORITY", "ASSIGNEE", "TITLE") + rows = [ + ( + i.identifier, + i.state.name if i.state else "-", + _priority_label(i.priority), + i.assignee.name if i.assignee else "-", + i.title, + ) + for i in issues + ] + widths = [ + max(len(header), *(len(row[n]) for row in rows)) if rows else len(header) + for n, header in enumerate(columns) + ] + typer.echo(" ".join(header.ljust(widths[n]) for n, header in enumerate(columns))) + typer.echo(" ".join("-" * w for w in widths)) + for row in rows: + typer.echo(" ".join(cell.ljust(widths[n]) for n, cell in enumerate(row))) + if verbose: + for issue in issues: + typer.echo(f"\n{issue.identifier} {issue.url}") + typer.echo(f" description: {issue.description or '-'}") + + +# --- commands --- + + +@app.command() +def viewer( + as_json: bool = typer.Option( + False, + "--json", + help="Emit JSON instead of human-readable output.", + ), +) -> None: + """Auth check: print the user the API key belongs to.""" + with _workflow() as linear: + user = linear.get_viewer() + if as_json: + typer.echo( + json.dumps({"id": user.id, "name": user.name, "email": user.email}, indent=2), + ) + else: + typer.echo(f"{user.name} <{user.email}> (id: {user.id})") + + +@app.command() +def teams( + as_json: bool = typer.Option( + False, + "--json", + help="Emit JSON instead of human-readable output.", + ), +) -> None: + """List teams (key, name, id).""" + # Not wrapped by the SDK, so this uses the raw GraphQL escape hatch. + with _workflow() as linear: + data = linear.client.execute( + "query { teams(first: 100) { nodes { id key name } } }", + ) + nodes = data["teams"]["nodes"] + if as_json: + typer.echo(json.dumps(nodes, indent=2)) + return + for team in nodes: + typer.echo(f"{team['key']:<10} {team['name']} (id: {team['id']})") + + +@app.command() +def issues( + team: str = typer.Option(..., help="Team key, e.g. ENG."), + state: State = typer.Option( + State.open, + help="Open (default) excludes completed and canceled issues.", + ), + limit: int = typer.Option( + 25, + min=1, + max=MAX_PAGE_SIZE, + help=f"Max issues to fetch (1-{MAX_PAGE_SIZE}, default 25).", + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Also print descriptions."), + as_json: bool = typer.Option( + False, + "--json", + help="Emit JSON instead of human-readable output.", + ), +) -> None: + """List a team's issues, newest updated first.""" + with _workflow() as linear: + resolved = linear.get_team_by_key(team) + if resolved is None: + _fail(f"no Linear team with key {team!r}") + if state is State.open: + fetched = linear.list_open_team_issues(resolved.id, first=limit) + else: + page = linear.list_issues_page( + {"team": {"id": {"eq": resolved.id}}}, + first=limit, + order_by=PaginationOrderBy.updatedAt, + ) + fetched = list(page.nodes) + _print_issues(fetched, as_json=as_json, verbose=verbose) + + +@app.command() +def issue( + identifier: str = typer.Argument(help="Issue identifier, e.g. ENG-123."), + as_json: bool = typer.Option( + False, + "--json", + help="Emit JSON instead of human-readable output.", + ), +) -> None: + """Fetch one issue by its human identifier.""" + identifier = identifier.upper() + with _workflow() as linear: + # get_issue() only accepts Linear UUIDs, so resolve the human identifier + # through search and match on the exact identifier. + result = linear.search_issues(identifier, first=25) + match = next((i for i in result.nodes if i.identifier == identifier), None) + if match is None: + _fail(f"no issue found with identifier {identifier}") + if as_json: + typer.echo(json.dumps(_issue_dict(match), indent=2)) + return + typer.echo(f"{match.identifier}: {match.title}") + typer.echo(f" url: {match.url}") + typer.echo(f" state: {match.state.name if match.state else '-'}") + typer.echo(f" priority: {_priority_label(match.priority)}") + typer.echo(f" assignee: {match.assignee.name if match.assignee else '-'}") + typer.echo(f" id: {match.id}") + if match.description: + typer.echo(" description:") + for line in match.description.splitlines(): + typer.echo(f" {line}") + + +@app.command() +def search( + term: str = typer.Argument(help="Search term."), + limit: int = typer.Option( + 10, + min=1, + max=MAX_PAGE_SIZE, + help=f"Max results (1-{MAX_PAGE_SIZE}, default 10).", + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Also print descriptions."), + as_json: bool = typer.Option( + False, + "--json", + help="Emit JSON instead of human-readable output.", + ), +) -> None: + """Free-text issue search across the workspace.""" + with _workflow() as linear: + result = linear.search_issues(term, first=limit) + _print_issues(list(result.nodes), as_json=as_json, verbose=verbose) + + +def main() -> None: + """Entry point: run the Typer app, mapping SDK errors to a clean exit.""" + try: + app() + except LinearAPIError as exc: + typer.secho(f"Linear API error: {exc}", fg=typer.colors.RED, err=True) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py new file mode 100644 index 00000000..6a45a035 --- /dev/null +++ b/tests/test_collapsible.py @@ -0,0 +1,191 @@ +"""Tests for the Collapsible panel's class_name shield. + +``CollapsiblePanel`` carries the structural ``transition-[height]`` animation +on ``ClassNames.PANEL``. Because ``cn()`` resolves via tailwind-merge +(client-side), any user-supplied ``transition-*`` utility merged onto the *same* +element drops ``transition-[height]`` (same twMerge conflict group, last-wins). + +``AccordionPanel`` was hardened in 755f3ed by popping the user's ``class_name`` +off the panel props and routing it to an inner content wrapper, so the panel +element always retains its structural transition. These tests verify +``CollapsiblePanel`` adopts the same shield and that the escape hatches +(``unstyled``, ``render_``) keep working through the new code path. + +``cn()`` returns a deferred reflex ``Var`` (a serialized JS call template), so +its ``str()`` form embeds both literal inputs — see ``tests/test_context_menu.py`` +for the established assertion convention. +""" + +import sys +from pathlib import Path +from typing import cast + +ROOT_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT_DIR)) + +import reflex as rx +from reflex.components.component import Component + +from components.ui.accordion import AccordionPanel +from components.ui.collapsible import ClassNames, CollapsiblePanel +from components.ui.core import BaseUIComponent + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _panel(class_name: str | None = None, **props) -> BaseUIComponent: + """Build a panel with a single child, optionally passing class_name.""" + if class_name is None: + return CollapsiblePanel.create(rx.el.div("content"), **props) + return CollapsiblePanel.create(rx.el.div("content"), class_name=class_name, **props) + + +def _inner_div(panel: BaseUIComponent) -> Component: + """Return the inner content wrapper (the panel's sole child). + + ``children`` is typed ``list[BaseComponent]`` and ``BaseComponent`` does not + expose ``class_name``; the inner wrapper is an ``rx.el.div`` (a ``Component``), + so we downcast to access the merged class_name Var. + """ + children = panel.children + assert len(children) == 1, "panel should wrap content in a single div" + return cast(Component, children[0]) + + +def _shielded_panel_class_name() -> str: + """The exact serialized form the panel element must always take: cn(PANEL, "").""" + return f'(cn("{ClassNames.PANEL}", ""))' + + +# --------------------------------------------------------------------------- +# Structural shield: the panel element never receives the user's class_name +# --------------------------------------------------------------------------- + + +def test_panel_wraps_children_in_a_single_inner_div() -> None: + """The panel must wrap its children in a single inner ``rx.el.div``.""" + panel = _panel(class_name="custom-panel") + inner = _inner_div(panel) + assert type(inner).__name__ == "Div" + assert getattr(inner, "tag", None) == "div" + + +def test_panel_default_inner_wrapper_is_empty() -> None: + """Without a user class_name, the inner wrapper carries no styling (no double-padding).""" + panel = _panel() + assert str(_inner_div(panel).class_name) == '(cn("", ""))' + + +def test_panel_class_name_is_always_shielded_from_user_class_name() -> None: + """ + The panel element's class_name must be exactly ``cn(PANEL, "")`` regardless of + what the caller passes — the user's class_name is never merged onto the + transition-bearing panel element. This is the structural guarantee that + shields ``transition-[height]`` from tailwind-merge conflicts. + """ + panel = _panel(class_name="transition-[opacity] duration-500") + assert str(panel.class_name) == _shielded_panel_class_name() + + +# --------------------------------------------------------------------------- +# The bug: user-supplied transition-* must not drop transition-[height] +# --------------------------------------------------------------------------- + + +def test_panel_keeps_height_transition_for_transition_star_utilities() -> None: + """ + Regression for the height-animation bug: any ``transition-*`` utility passed + via ``class_name`` shares tailwind-merge's transition-property conflict group + with the structural ``transition-[height]`` and would drop it if merged onto + the panel element. The panel must keep ``transition-[height]`` and route the + user utility to the inner wrapper. + """ + for user_class in ( + "transition-all", + "transition-opacity", + "transition-colors", + "transition-transform", + "transition", + "transition-[background-color]", + "transition-[opacity] duration-500", + ): + panel = _panel(class_name=user_class) + # Panel element is always exactly cn(PANEL, "") — transition-[height] survives. + assert str(panel.class_name) == _shielded_panel_class_name(), ( + f"panel element changed for user class {user_class!r}" + ) + assert "transition-[height]" in str(panel.class_name) + # The user utility routes to the inner wrapper, not the panel element. + assert user_class in str(_inner_div(panel).class_name), ( + f"user class {user_class!r} not routed to inner wrapper" + ) + + +def test_panel_routes_non_conflicting_user_classes_to_inner_wrapper_too() -> None: + """ + The shield is unconditional: non-conflicting utilities also route to the inner + wrapper (the panel element is *always* ``cn(PANEL, "")``), bringing Collapsible + into architectural parity with Accordion — callers cannot style the animated + panel box directly via ``class_name``. + """ + for user_class in ("duration-500", "bg-blue-500 p-4", "border-t border-border/40"): + panel = _panel(class_name=user_class) + assert str(panel.class_name) == _shielded_panel_class_name() + assert user_class in str(_inner_div(panel).class_name) + + +# --------------------------------------------------------------------------- +# Escape hatches still work through the new code path +# --------------------------------------------------------------------------- + + +def test_panel_unstyled_omits_default_classes() -> None: + """unstyled=True bypasses the default PANEL class on the panel element.""" + panel = _panel(class_name="custom", unstyled=True) + assert str(panel.class_name) == "" + + +def test_panel_render_skips_default_class_merge() -> None: + """ + render_ is a full-replacement escape hatch: set_class_name returns early, so the + panel element applies neither the default PANEL class nor the user's class_name + (panel class_name is None). The user's class_name still routes to the inner + wrapper, mirroring AccordionPanel's behavior under render_. + """ + panel = CollapsiblePanel.create( + rx.el.div("content"), + class_name="transition-[opacity]", + render_=rx.el.div("replacement"), + ) + assert panel.class_name is None + assert "transition-[opacity]" in str(_inner_div(panel).class_name) + + +# --------------------------------------------------------------------------- +# Parity with AccordionPanel (the established shield pattern) +# --------------------------------------------------------------------------- + + +def test_panel_shield_matches_accordion_panel_shield() -> None: + """ + CollapsiblePanel and AccordionPanel must shield the panel element's + ``transition-[height]`` the same way: the panel element's class_name is + ``cn(, "")`` and the user class_name lands on an inner div. + This guards against the two components diverging again — the divergence + between them is exactly what introduced this bug. + """ + cpanel = CollapsiblePanel.create( + rx.el.div("c"), class_name="transition-[opacity] duration-500" + ) + apanel = AccordionPanel.create( + rx.el.div("a"), class_name="transition-[opacity] duration-500" + ) + + assert "transition-[height]" in str(cpanel.class_name) + assert "transition-[height]" in str(apanel.class_name) + assert "transition-[opacity]" not in str(cpanel.class_name) + assert "transition-[opacity]" not in str(apanel.class_name) + assert "transition-[opacity]" in str(_inner_div(cpanel).class_name) + assert "transition-[opacity]" in str(_inner_div(apanel).class_name) diff --git a/tests/test_context_menu.py b/tests/test_context_menu.py new file mode 100644 index 00000000..f41d9ba9 --- /dev/null +++ b/tests/test_context_menu.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT_DIR)) + +from components.ui.context_menu import ClassNames, ContextMenuShortcut # noqa: E402 + + +def test_context_menu_shortcut_uses_default_classes() -> None: + shortcut = ContextMenuShortcut.create("Ctrl+C") + + assert ClassNames.SHORTCUT in str(shortcut.class_name) + + +def test_context_menu_shortcut_merges_custom_class_name() -> None: + shortcut = ContextMenuShortcut.create("Ctrl+C", class_name="custom-shortcut") + + assert ClassNames.SHORTCUT in str(shortcut.class_name) + assert "custom-shortcut" in str(shortcut.class_name) + + +def test_context_menu_shortcut_unstyled_omits_default_classes() -> None: + shortcut = ContextMenuShortcut.create("Ctrl+C", unstyled=True) + + assert str(shortcut.class_name) == "" + + +def test_context_menu_shortcut_unstyled_keeps_custom_class_name() -> None: + shortcut = ContextMenuShortcut.create( + "Ctrl+C", class_name="custom-shortcut", unstyled=True + ) + + assert str(shortcut.class_name) == "custom-shortcut" diff --git a/tests/test_linear_cli.py b/tests/test_linear_cli.py new file mode 100644 index 00000000..e4763360 --- /dev/null +++ b/tests/test_linear_cli.py @@ -0,0 +1,243 @@ +"""Tests for the Typer-based Linear CLI (scripts/linear_cli.py). + +The SDK layer is mocked everywhere except the missing-key test, which +exercises the real pydantic-settings resolution against an empty directory +(no env var, no .env files). +""" + +import json +import os +import sys +import types +import unittest.mock as mock +from pathlib import Path + +import pytest +from pydantic import ValidationError +from typer.testing import CliRunner + +# --------------------------------------------------------------------------- +# Import the script under test — the repo root is not a package, and `scripts` +# is not part of the installed wheel, so put the root on sys.path explicitly. +# --------------------------------------------------------------------------- +ROOT_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT_DIR)) + +from gtm_linear import LinearAPIError, PaginationOrderBy # noqa: E402 +from scripts import linear_cli # noqa: E402 + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _issue(identifier: str = "EKK-1", title: str = "Test issue") -> types.SimpleNamespace: + """A stand-in IssueFields object with the fields the CLI reads.""" + return types.SimpleNamespace( + identifier=identifier, + title=title, + description=None, + url=f"https://linear.app/me319/issue/{identifier}", + state=types.SimpleNamespace(name="Backlog"), + priority=2.0, + assignee=None, + id="issue-uuid", + ) + + +def _patch_workflow(inner: mock.MagicMock) -> mock._patch: + """Patch ``linear_cli._workflow`` so its context yields ``inner``. + + ``__exit__`` must return False so exceptions raised inside the ``with`` + block (e.g. typer.Exit) propagate to the runner instead of being swallowed. + """ + workflow = mock.MagicMock() + workflow.__enter__.return_value = inner + workflow.__exit__.return_value = False + return mock.patch.object(linear_cli, "_workflow", return_value=workflow) + + +def _team() -> types.SimpleNamespace: + return types.SimpleNamespace(id="team-uuid", key="EKK", name="Elvis") + + +# --------------------------------------------------------------------------- +# Issues listing +# --------------------------------------------------------------------------- + + +def test_issues_default_state_open_uses_open_helper() -> None: + inner = mock.MagicMock() + inner.get_team_by_key.return_value = _team() + inner.list_open_team_issues.return_value = [_issue()] + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["issues", "--team", "EKK"]) + + assert result.exit_code == 0 + inner.list_open_team_issues.assert_called_once_with("team-uuid", first=25) + inner.list_issues_page.assert_not_called() + assert "EKK-1" in result.output + + +def test_issues_state_all_uses_filtered_page() -> None: + inner = mock.MagicMock() + inner.get_team_by_key.return_value = _team() + inner.list_issues_page.return_value = types.SimpleNamespace(nodes=[_issue()]) + + with _patch_workflow(inner): + result = runner.invoke( + linear_cli.app, + ["issues", "--team", "EKK", "--state", "all"], + ) + + assert result.exit_code == 0 + inner.list_open_team_issues.assert_not_called() + args, kwargs = inner.list_issues_page.call_args + assert args[0] == {"team": {"id": {"eq": "team-uuid"}}} + assert kwargs.get("order_by") is PaginationOrderBy.updatedAt + assert "EKK-1" in result.output + + +def test_issues_json_output_parses() -> None: + inner = mock.MagicMock() + inner.get_team_by_key.return_value = _team() + inner.list_open_team_issues.return_value = [_issue()] + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["issues", "--team", "EKK", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload[0]["identifier"] == "EKK-1" + assert payload[0]["state"] == "Backlog" + + +def test_unknown_team_exits_1() -> None: + inner = mock.MagicMock() + inner.get_team_by_key.return_value = None + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["issues", "--team", "NOPE"]) + + assert result.exit_code == 1 + assert "no Linear team" in result.output + + +@pytest.mark.parametrize("bad_limit", ["0", "-5", "500"]) +def test_out_of_range_limit_is_a_usage_error(bad_limit: str) -> None: + result = runner.invoke( + linear_cli.app, + ["issues", "--team", "EKK", "--limit", bad_limit], + ) + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# Single issue and search +# --------------------------------------------------------------------------- + + +def test_issue_found_prints_details_and_uppercases_identifier() -> None: + inner = mock.MagicMock() + inner.search_issues.return_value = types.SimpleNamespace(nodes=[_issue()]) + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["issue", "ekk-1"]) + + assert result.exit_code == 0 + inner.search_issues.assert_called_once_with("EKK-1", first=25) + assert "EKK-1: Test issue" in result.output + + +def test_issue_not_found_exits_1() -> None: + inner = mock.MagicMock() + inner.search_issues.return_value = types.SimpleNamespace(nodes=[]) + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["issue", "EKK-999"]) + + assert result.exit_code == 1 + assert "no issue found" in result.output + + +# --------------------------------------------------------------------------- +# Auth and error mapping +# --------------------------------------------------------------------------- + + +def test_missing_api_key_exits_1(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Integration smoke test of the real settings resolution; the patched + # test below is the authoritative one for CLI handling. LinearSettings + # reads LINEAR_* env vars and .env/.env.local relative to the cwd, so an + # empty tmp_path with every LINEAR_* var cleared hides all key sources. + # Matching is case-insensitive because pydantic-settings resolves env + # var names case-insensitively by default. + for name in [n for n in os.environ if n.upper().startswith("LINEAR_")]: + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(linear_cli.app, ["viewer"]) + + assert result.exit_code == 1 + assert "no LINEAR_API_KEY" in result.output + + +def test_from_env_validation_error_exits_1() -> None: + # Covers the CLI's handling of a missing key even if the SDK's settings + # resolution ever changes to read other sources. + error = ValidationError.from_exception_data( + "LinearSettings", + [{"type": "missing", "loc": ("api_key",), "input": {}}], + ) + + with mock.patch.object(linear_cli.LinearClient, "from_env", side_effect=error): + result = runner.invoke(linear_cli.app, ["viewer"]) + + assert result.exit_code == 1 + assert "no LINEAR_API_KEY" in result.output + + +def test_unrelated_from_env_error_is_not_misreported() -> None: + # Only ValidationError maps to the "no LINEAR_API_KEY" message. ValueError + # is chosen deliberately: pydantic v2's ValidationError subclasses it, so a + # future `except ValueError` in _workflow() would swallow this and turn it + # into a misleading missing-key message — the regression this guards against. + with mock.patch.object( + linear_cli.LinearClient, + "from_env", + side_effect=ValueError("kaboom"), + ): + result = runner.invoke(linear_cli.app, ["viewer"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, ValueError) + assert str(result.exception) == "kaboom" + assert "no LINEAR_API_KEY" not in result.output + + +def test_main_maps_linear_api_error_to_clean_stderr_and_exit_1(capsys) -> None: + with mock.patch.object(linear_cli, "app", side_effect=LinearAPIError("boom")): + with pytest.raises(SystemExit) as excinfo: + linear_cli.main() + + assert excinfo.value.code == 1 + stderr = capsys.readouterr().err + assert "Linear API error" in stderr + assert "boom" in stderr + + +def test_linear_api_error_propagates_out_of_app() -> None: + # The command layer lets LinearAPIError escape; only main() maps it to a + # clean exit (covered by the test above). + inner = mock.MagicMock() + inner.get_viewer.side_effect = LinearAPIError("boom") + + with _patch_workflow(inner): + result = runner.invoke(linear_cli.app, ["viewer"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, LinearAPIError) diff --git a/uv.lock b/uv.lock index d891e405..26f29d84 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,19 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [options] prerelease-mode = "allow" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -52,6 +61,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "gtm-linear" }, { name = "pillow" }, { name = "pytest" }, { name = "pyyaml" }, @@ -59,12 +69,14 @@ dev = [ { name = "reflex" }, { name = "reflex-components-internal" }, { name = "reflex-hosting-cli" }, + { name = "typer" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ + { name = "gtm-linear", specifier = ">=0.2.1" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -72,6 +84,7 @@ dev = [ { name = "reflex", specifier = "==0.9.6" }, { name = "reflex-components-internal" }, { name = "reflex-hosting-cli", specifier = ">=0.1.67" }, + { name = "typer", specifier = ">=0.27.2" }, ] [[package]] @@ -188,6 +201,20 @@ reload = [ { name = "watchfiles" }, ] +[[package]] +name = "gtm-linear" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/1c/f2bd0b47b7d7f0a7d67c895dff9fc4b419611ae1c5a4eef666f38f03d2dd/gtm_linear-0.2.1.tar.gz", hash = "sha256:3924da8a422f30292cf8a4a8a01e3286dc058de01d53855c069d97b19e8ce286", size = 248678, upload-time = "2026-09-29T18:29:55.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/21/842ddc0bb3bc0eebee2de949d536d2a7d7387003973e120fcc62a910f6e1/gtm_linear-0.2.1-py3-none-any.whl", hash = "sha256:330f257af7dd7689d621c19272ca131e3caaa02d157b64e44979de5fcd9812b2", size = 35303, upload-time = "2026-09-29T18:29:53.802Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -522,6 +549,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/0c/90941408bb6c1e84563d99177c9e62c536dce0388b5a8bf37b90b4f6f095/pydantic_core-2.47.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0ae0096729c64aa6155cef74e75d1945017b01cfb82f313a18d843fafc497476", size = 2180871, upload-time = "2026-05-22T13:19:56.696Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -547,6 +588,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + [[package]] name = "python-engineio" version = "4.13.2" @@ -915,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -940,6 +999,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"