Source code for scaffolder.widget_generator

#!/usr/bin/env python3
"""
scaffolder/widget_generator.py - Generates the default <Widget>.tsx file
"""
from pathlib import Path
from typing import Any

from scaffolder.utils import _write, gql_to_json_type


def _with_unique(values: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for value in values:
        if value in seen:
            continue
        seen.add(value)
        out.append(value)
    return out


def _to_ts_type(gql_type: str) -> str:
    json_t = gql_to_json_type(gql_type)
    if json_t in ("integer", "number"):
        return "number"
    if json_t == "boolean":
        return "boolean"
    if json_t == "array":
        return "any[]"
    return "string"


[docs] def generate_widget( pascal: str, camel: str, var_defaults: dict[str, Any], var_types: dict[str, str], operations: list[dict[str, Any]], dest: Path, *, feature_flags: dict[str, bool] | None = None, extra_props: dict[str, str] | None = None, widget_options: dict[str, Any] | None = None, ) -> None: flags = feature_flags or {} extra_props = extra_props or {} widget_options = widget_options or {} poll_count = sum( 1 for op in operations if op.get("type", "").lower() == "polling" ) stream_count = sum( 1 for op in operations if op.get("type", "").lower() == "stream" ) mut_count = sum( 1 for op in operations if op.get("type", "").lower() == "mutation" ) has_poll = poll_count > 0 has_stream = stream_count > 0 has_mut = mut_count > 0 has_workspace_switch = bool(flags.get("workspace_switch")) has_variable_selector = bool(flags.get("variable_selector")) has_starter_table = bool(flags.get("starter_table")) has_title = "title" in extra_props has_live_data_toggle = bool(widget_options.get("live_data_toggle")) tango_attrs = widget_options.get("tango_attributes") or {} has_tango_attributes = bool(tango_attrs) optional_vars = set(widget_options.get("optional_vars") or []) needs_host_vars = ( has_title or has_variable_selector or has_tango_attributes ) needs_state = has_mut or has_workspace_switch or has_variable_selector var_names = sorted(var_defaults) lines: list[str] = [] react_imports = ["useMemo"] if needs_state: react_imports.append("useState") lines.append(f"import {{ {', '.join(react_imports)} }} from 'react';") sdk_imports: list[str] = [] if has_poll: sdk_imports.append("useWidgetRefreshRate") if has_stream: sdk_imports.append("useStream") if has_mut: sdk_imports.append("CommandResultAlert") if has_workspace_switch: sdk_imports.append("useWorkspace") if needs_host_vars: sdk_imports.append("useVariables") sdk_imports.append("useWidgetLayout") if has_title: sdk_imports.append("formatInputFormattedTemplate") if has_tango_attributes: sdk_imports.append("normalizeTangoAttributeInputs") if has_starter_table: sdk_imports += ["InteractiveTable", "useInteractiveTableState"] sdk_imports = _with_unique(sdk_imports) lines.append( "import { " + ", ".join(sdk_imports) + " } from '@ska-octopus-widget-sdk/widget-sdk';" ) apollo_imports: list[str] = [] if has_poll: apollo_imports.append("useQuery") if has_mut: apollo_imports.append("useMutation as useGqlMutation") if apollo_imports: lines.append( "import { " + ", ".join(apollo_imports) + " } from '@apollo/client';" ) gql_docs = [] if has_poll: gql_docs.append("pollingDocs") if has_stream: gql_docs.append("streamDocs") if has_mut: gql_docs.append("mutationDocs") if gql_docs: lines.append( "import { " + ", ".join(gql_docs) + " } from './graphql/ops';" ) lines.append(f"import styles from './{pascal}Widget.module.css';") lines.append("") lines.append(f"export interface {pascal}WidgetProps {{") lines.append(" /** dashboard-assigned instance key */") lines.append(" instanceId?: string;") for var_name in var_names: prop_name = f"{var_name}?" if var_name in optional_vars else var_name lines.append( f" {prop_name}: {_to_ts_type(var_types.get(var_name, 'String'))};" ) for prop_name, prop_type in sorted(extra_props.items()): lines.append(f" {prop_name}: {prop_type};") lines.append("}") lines.append("") lines.append( f"export default function {pascal}Widget(config: {pascal}WidgetProps) {{" ) if has_poll: lines.append( " const refresh = useWidgetRefreshRate(" f"config.instanceId ?? '{camel}'" ");" ) if needs_host_vars: if has_variable_selector: lines.append( " const { vars: hostVars, setVariable } = useVariables();" ) else: lines.append(" const { vars: hostVars } = useVariables();") if has_title: lines += [ " const title = useMemo(", " () =>", " formatInputFormattedTemplate({", " template: config.title,", " variables: hostVars,", " fallback: config.title || '',", " }),", " [config.title, hostVars]", " );", ] if has_tango_attributes: config_key = tango_attrs.get("config_key", "attributes") full_names_var = tango_attrs.get("full_names_var", "fullNames") endpoint_var = tango_attrs.get("endpoint_var", "endpoint") lines += [ " const resolvedTangoAttributeRoutes = useMemo(", " () =>", " normalizeTangoAttributeInputs({", f" attributes: config.{config_key},", f" fallbackAttributes: config.{full_names_var},", f" fallbackEndpoint: config.{endpoint_var},", " variables: hostVars,", " }),", " [", f" config.{config_key},", f" config.{full_names_var},", f" config.{endpoint_var},", " hostVars,", " ]", " );", " const resolvedTangoSelection = useMemo(() => {", " let endpoint = '';", " const fullNames: string[] = [];", " for (const route of resolvedTangoAttributeRoutes) {", " const trimmed = String(route ?? '').trim();", " const match = /^(.+):\\/\\/(.+)$/.exec(trimmed);", " if (match) {", " endpoint = endpoint || String(match[1] ?? '').trim();", " fullNames.push(String(match[2] ?? '').trim());", " } else if (trimmed) {", " fullNames.push(trimmed);", " }", " }", f" return {{ endpoint: endpoint || config.{endpoint_var} || '', fullNames }};", f" }}, [resolvedTangoAttributeRoutes, config.{endpoint_var}]);", " const resolvedTangoSelectionSig = useMemo(", " () => JSON.stringify(resolvedTangoSelection),", " [resolvedTangoSelection]", " );", ] if var_names: lines.append(" const vars = useMemo(() => ({") for name in var_names: if has_tango_attributes and name == full_names_var: lines.append(f" {name}: resolvedTangoSelection.fullNames,") elif has_tango_attributes and name == endpoint_var: lines.append( f" {name}: resolvedTangoSelection.endpoint || undefined," ) else: lines.append(f" {name}: config.{name},") lines.append(" }), [") for name in var_names: if has_tango_attributes and name in {full_names_var, endpoint_var}: lines.append(" resolvedTangoSelectionSig,") else: lines.append(f" config.{name},") lines.append(" ]);") else: lines.append(" const vars = useMemo(() => ({}), []);") lines += [ " const layout = useWidgetLayout(config.instanceId);", " const containerStyle = useMemo(", " () => ({", " width: '100%',", " height: '100%',", " maxWidth: layout?.width ? `${Math.max(layout.width, 0)}px` : '100%',", " maxHeight: layout?.height ? `${Math.max(layout.height, 0)}px` : '100%',", " }),", " [layout?.height, layout?.width]", " );", "", ] if has_workspace_switch: lines += [ " const { currentWorkspace, workspaceKeys, setWorkspace } = useWorkspace();", " const workspaceOptions = useMemo(", " () => Array.from(new Set([currentWorkspace, ...workspaceKeys].filter(Boolean))),", " [currentWorkspace, workspaceKeys]", " );", " const [workspaceTarget, setWorkspaceTarget] = useState(", " config.workspaceTarget || currentWorkspace || ''", " );", " function applyWorkspaceTarget() {", " const nextWorkspace = workspaceTarget || config.workspaceTarget || currentWorkspace;", " if (!nextWorkspace) return;", " setWorkspace(nextWorkspace);", " }", "", ] if has_variable_selector: lines += [ " const variableOptions = useMemo(", " () =>", " Array.from(new Set([config.selectedVariableKey, ...Object.keys(hostVars)].filter(Boolean))).sort(", " (a, b) => a.localeCompare(b)", " ),", " [config.selectedVariableKey, hostVars]", " );", " const [selectedVariableKey, setSelectedVariableKey] = useState(", " config.selectedVariableKey || ''", " );", " const [selectedVariableValue, setSelectedVariableValue] = useState(", " config.selectedVariableValue || ''", " );", " function applyVariableChange() {", " const key = selectedVariableKey || config.selectedVariableKey;", " if (!key) return;", " setVariable(key, selectedVariableValue);", " }", "", ] if has_poll: lines.append(" /* polling */") for i in range(poll_count): lines += [ f" const pollResult{i} = useQuery(pollingDocs[{i}], {{", " variables: vars,", ( " skip: !!config.useLiveData," if has_live_data_toggle else None ), " pollInterval: refresh * 1000,", " fetchPolicy: 'network-only'", " });", ] lines = [line for line in lines if line is not None] poll_refs = ", ".join(f"pollResult{i}" for i in range(poll_count)) lines.append(f" const pollResults = [{poll_refs}];") lines.append("") if has_stream: lines.append(" /* streams */") for i in range(stream_count): if has_live_data_toggle: lines.append( " const streamResult" + str(i) + " = useStream({ " + f"document: streamDocs[{i}], variables: vars, coalesce: !!config.useLiveData, client: config.useLiveData ? undefined : null " + "});" ) else: lines.append( f" const streamResult{i} = useStream({{ document: streamDocs[{i}], variables: vars }});" ) stream_refs = ", ".join( f"streamResult{i}" for i in range(stream_count) ) lines.append(f" const streamResults = [{stream_refs}];") lines.append("") if has_live_data_toggle: lines += [ " const activePollResults = config.useLiveData ? [] : pollResults;", " const activeStreamResults = config.useLiveData ? streamResults : [];", "", ] if has_mut: for i in range(mut_count): lines.append( f" const mutation{i} = useGqlMutation(mutationDocs[{i}]);" ) mut_refs = ", ".join(f"mutation{i}" for i in range(mut_count)) lines += [ " /* mutations */", f" const mutations = [{mut_refs}];", " const [toast, setToast] = useState({", " open: false, ok: true, message: '', output: null as any", " });", "", " async function fire(i: number) {", " try {", " const [run] = mutations[i];", " const out = await run({ variables: vars });", " setToast({ open: true, ok: true, message: 'Success', output: out.data });", " } catch (e: any) {", " setToast({ open: true, ok: false, message: e.message, output: null });", " }", " }", "", ] if has_starter_table: if has_poll or has_stream: lines += [ " const tableRows = useMemo(() => {", " const rows: Array<{ id: string; source: string; payload: string }> = [];", ] if has_poll: lines += [ " pollResults.forEach((result, idx) => {", " rows.push({", " id: `poll-${idx}`,", " source: `polling #${idx + 1}`,", " payload: JSON.stringify(result.data ?? null),", " });", " });", ] if has_stream: lines += [ " streamResults.forEach((result, idx) => {", " rows.push({", " id: `stream-${idx}`,", " source: `stream #${idx + 1}`,", " payload: JSON.stringify(result.data ?? null),", " });", " });", ] lines += [ " if (!rows.length) {", " rows.push({ id: 'empty', source: 'example', payload: 'No data yet' });", " }", " return rows;", " }, [", ] if has_poll: lines.append(" pollResults,") if has_stream: lines.append(" streamResults,") lines += [" ]);", ""] else: lines += [ " const tableRows = useMemo(", " () => [", " { id: 'example-1', source: 'example', payload: 'Starter table row 1' },", " { id: 'example-2', source: 'example', payload: 'Starter table row 2' },", " ],", " []", " );", "", ] lines += [ " const tableColumns = useMemo(", " () => [", " { key: 'source', label: 'Source', renderCell: ({ row }: any) => row.source },", " { key: 'payload', label: 'Payload', renderCell: ({ row }: any) => row.payload },", " ],", " []", " );", " const tableState = useInteractiveTableState({", " columns: ['source', 'payload'] as const,", " defaultSortKey: 'source',", " getDefaultWidth: (column: 'source' | 'payload') => (column === 'source' ? 160 : 420),", " getMinWidth: (column: 'source' | 'payload') => (column === 'source' ? 120 : 180),", " });", "", ] loaders = [] errors = [] if has_poll: loaders.append( "...activePollResults" if has_live_data_toggle else "...pollResults" ) errors.append( "...activePollResults" if has_live_data_toggle else "...pollResults" ) if has_stream: loaders.append( "...activeStreamResults" if has_live_data_toggle else "...streamResults" ) errors.append( "...activeStreamResults" if has_live_data_toggle else "...streamResults" ) if loaders: lines += [ " const anyLoading = [" + ", ".join(loaders) + "].some((r) =>", " 'loading' in r ? r.loading : r.status === 'loading'", " );", ] else: lines.append(" const anyLoading = false;") if errors: lines.append( " const anyError = [" + ", ".join(errors) + "].find((r) => r.error);" ) else: lines.append(" const anyError = undefined;") lines += [ "", " if (anyLoading) {", " return (", " <div className={styles.container} style={containerStyle}>", " <div className={styles.content}>", " <div className={styles.results}>", " <div className={styles.status}>Loading…</div>", " </div>", " </div>", " </div>", " );", " }", "", " if (anyError) {", " return (", " <div className={styles.container} style={containerStyle}>", " <div className={styles.content}>", " <div className={styles.results}>", " <div className={`${styles.status} ${styles.error}`}>{String(anyError.error)}</div>", " </div>", " </div>", " </div>", " );", " }", "", " return (", " <div className={styles.container} style={containerStyle}>", " <div className={styles.content}>", ] if has_title: lines += [ " {title ? <h3 style={{ margin: '0 0 8px 0' }}>{title}</h3> : null}", ] if has_workspace_switch: lines += [ " <div style={{ marginBottom: 8, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>", " <strong>Workspace</strong>", " <select value={workspaceTarget} onChange={e => setWorkspaceTarget(e.target.value)}>", " <option value=''>Select workspace</option>", " {workspaceOptions.map(key => (", " <option key={key} value={key}>{key}</option>", " ))}", " </select>", " <button type='button' onClick={applyWorkspaceTarget}>Apply workspace</button>", " <span style={{ opacity: 0.7 }}>Current: {currentWorkspace || '—'}</span>", " </div>", ] if has_variable_selector: lines += [ " <div style={{ marginBottom: 8, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>", " <strong>Variable</strong>", " <select value={selectedVariableKey} onChange={e => setSelectedVariableKey(e.target.value)}>", " <option value=''>Select variable</option>", " {variableOptions.map(key => (", " <option key={key} value={key}>{key}</option>", " ))}", " </select>", " <input type='text' value={selectedVariableValue} onChange={e => setSelectedVariableValue(e.target.value)} placeholder='value' />", " <button type='button' onClick={applyVariableChange}>Set variable</button>", " </div>", ] if has_mut: lines += [ " <div className={styles.controls}>", " {mutations.map((_, i) => (", " <button className={styles.button} key={i} onClick={() => fire(i)}>", " Run mutation #{i + 1}", " </button>", " ))}", " </div>", ] if has_starter_table: lines += [ " <div style={{ marginBottom: 12 }}>", " <h4 style={{ margin: '0 0 6px 0' }}>Starter interactive table</h4>", " <InteractiveTable", " state={tableState}", " columns={tableColumns as any}", " rows={tableRows}", " getRowKey={row => row.id}", " />", " </div>", ] lines += [ " <div className={styles.results}>", " <pre className={styles.pre}>", " {JSON.stringify(", " {", ] debug_fields: list[str] = [] if has_poll: debug_fields.append( "polling: " + ( "activePollResults.map((r) => r.data)" if has_live_data_toggle else "pollResults.map((r) => r.data)" ) ) if has_stream: debug_fields.append( "streams: " + ( "activeStreamResults.map((r) => r.data)" if has_live_data_toggle else "streamResults.map((r) => r.data)" ) ) if has_live_data_toggle: debug_fields.append("useLiveData: config.useLiveData") if has_workspace_switch: debug_fields.append("currentWorkspace") if has_variable_selector: debug_fields.append("variables: hostVars") if has_starter_table: debug_fields.append("tableRows") for i, field in enumerate(debug_fields): suffix = "," if i < len(debug_fields) - 1 else "" lines.append(f" {field}{suffix}") lines += [ " },", " null,", " 2", " )}", " </pre>", " </div>", ] if has_mut: lines += [ " <CommandResultAlert", " open={toast.open}", " onClose={() => setToast((t) => ({ ...t, open: false }))}", " ok={toast.ok}", " message={toast.message}", " output={toast.output}", " />", ] lines += [ " </div>", " </div>", " );", "}", "", ] out_path = dest / f"src/{pascal}Widget.tsx" _write(out_path, "\n".join(lines))