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))