@growthub/cli 0.9.9 โ†’ 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,50 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ BarChart3,
7
+ Bolt,
8
+ ChevronDown,
9
+ Code2,
10
+ Columns3,
11
+ Copy,
12
+ Database,
13
+ Download,
14
+ ExternalLink,
15
+ FileText,
16
+ Filter,
17
+ Gauge,
18
+ Grid2X2,
19
+ Home,
20
+ Import,
21
+ Italic,
22
+ LayoutDashboard,
23
+ Link as LinkIcon,
24
+ List,
25
+ Maximize2,
26
+ Pencil,
27
+ PieChart,
28
+ Plus,
29
+ Quote,
30
+ Rows3,
31
+ Save,
32
+ Search,
33
+ Settings,
34
+ Sigma,
35
+ SlidersHorizontal,
36
+ Sparkles,
37
+ Table2,
38
+ Trash2,
39
+ Type,
40
+ X
41
+ } from "lucide-react";
5
42
  import {
6
43
  DASHBOARD_TEMPLATES,
44
+ KNOWN_AGGREGATIONS,
45
+ KNOWN_CHART_TYPES,
46
+ KNOWN_FILTER_CONJUNCTIONS,
47
+ KNOWN_FILTER_OPERATORS,
48
+ KNOWN_SORT_DIRECTIONS,
7
49
  SAMPLE_DATA_BINDINGS,
8
50
  SAMPLE_VIEW_ROWS,
9
51
  cloneTemplateToDashboard,
@@ -14,6 +56,60 @@ import {
14
56
  validateWorkspaceConfig,
15
57
  wrapWorkspaceTemplateExport
16
58
  } from "@/lib/workspace-schema";
59
+ import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
60
+
61
+ const DEFAULT_CHART_TYPE = "bar-vertical";
62
+ const DEFAULT_FILTER_OP = "and";
63
+ const DEFAULT_FILTER_OPERATOR = "contains";
64
+ const DEFAULT_SORT_DIRECTION = "asc";
65
+ const SUB_PANEL_ROOT = "root";
66
+
67
+ const CHART_TYPE_LABELS = {
68
+ "bar-vertical": "Vertical Bar",
69
+ "bar-horizontal": "Horizontal Bar",
70
+ "pie": "Pie",
71
+ "sum": "Sum",
72
+ "gauge": "Gauge"
73
+ };
74
+
75
+ const CHART_TYPE_ICONS = {
76
+ "bar-vertical": BarChart3,
77
+ "bar-horizontal": Rows3,
78
+ "pie": PieChart,
79
+ "sum": Sigma,
80
+ "gauge": Gauge
81
+ };
82
+
83
+ const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
84
+
85
+ const WIDGET_KIND_ICONS = {
86
+ chart: BarChart3,
87
+ view: Table2,
88
+ iframe: Code2,
89
+ "rich-text": FileText
90
+ };
91
+
92
+ const FILTER_OPERATOR_LABELS = {
93
+ eq: "equals",
94
+ ne: "does not equal",
95
+ contains: "contains",
96
+ gt: "is greater than",
97
+ lt: "is less than",
98
+ isEmpty: "is empty",
99
+ isNotEmpty: "is not empty"
100
+ };
101
+
102
+ const COLUMN_ICON_FOR = (name) => {
103
+ const lower = String(name || "").toLowerCase();
104
+ if (lower.includes("name") || lower.includes("title")) return "๐Ÿ›";
105
+ if (lower.includes("domain") || lower.includes("url") || lower.includes("link")) return "๐Ÿ”—";
106
+ if (lower.includes("address") || lower.includes("location")) return "๐Ÿ—บ";
107
+ if (lower.includes("employee") || lower.includes("people") || lower.includes("user")) return "๐Ÿ‘ฅ";
108
+ if (lower.includes("linkedin")) return "in";
109
+ if (lower.includes("twitter") || lower === "x") return "๐•";
110
+ if (lower.includes("date") || lower.includes("created") || lower.includes("updated")) return "๐Ÿ“…";
111
+ return "โ–ฆ";
112
+ };
17
113
 
18
114
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
19
115
  const GRID_COLUMNS = 12;
@@ -350,6 +446,11 @@ function widgetKindLabel(kind) {
350
446
  return kind.charAt(0).toUpperCase() + kind.slice(1);
351
447
  }
352
448
 
449
+ function IconGlyph({ icon: Icon, size = 16 }) {
450
+ if (!Icon) return null;
451
+ return <Icon aria-hidden="true" size={size} strokeWidth={1.9} />;
452
+ }
453
+
353
454
  function isLikelyHttpUrl(value) {
354
455
  if (typeof value !== "string" || !value.trim()) return false;
355
456
  try {
@@ -364,6 +465,29 @@ function cloneConfig(value) {
364
465
  return JSON.parse(JSON.stringify(value));
365
466
  }
366
467
 
468
+ function escapeHtml(value) {
469
+ return String(value || "")
470
+ .replaceAll("&", "&amp;")
471
+ .replaceAll("<", "&lt;")
472
+ .replaceAll(">", "&gt;")
473
+ .replaceAll('"', "&quot;")
474
+ .replaceAll("'", "&#039;");
475
+ }
476
+
477
+ function richTextToHtml(value) {
478
+ const escaped = escapeHtml(value || "Start writing...");
479
+ return escaped
480
+ .replace(/^### (.*)$/gm, "<h3>$1</h3>")
481
+ .replace(/^## (.*)$/gm, "<h2>$1</h2>")
482
+ .replace(/^# (.*)$/gm, "<h1>$1</h1>")
483
+ .replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>")
484
+ .replace(/^- (.*)$/gm, "<li>$1</li>")
485
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
486
+ .replace(/\*(.*?)\*/g, "<em>$1</em>")
487
+ .replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
488
+ .replace(/\n/g, "<br />");
489
+ }
490
+
367
491
  function normalizeChartValues(value) {
368
492
  return String(value)
369
493
  .split(",")
@@ -408,6 +532,129 @@ function serializeManualRows(rows, columns) {
408
532
  .join("\n");
409
533
  }
410
534
 
535
+ function getColumnList(widget) {
536
+ return Array.isArray(widget?.config?.columns) ? widget.config.columns : [];
537
+ }
538
+
539
+ function getOrderedColumns(widget) {
540
+ const columns = getColumnList(widget);
541
+ const order = Array.isArray(widget?.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : [];
542
+ if (!order.length) return columns;
543
+ const known = new Set(columns);
544
+ const ordered = order.filter((name) => known.has(name));
545
+ const remaining = columns.filter((name) => !ordered.includes(name));
546
+ return [...ordered, ...remaining];
547
+ }
548
+
549
+ function getHiddenColumnSet(widget) {
550
+ const hidden = Array.isArray(widget?.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [];
551
+ return new Set(hidden);
552
+ }
553
+
554
+ function getVisibleColumns(widget) {
555
+ const ordered = getOrderedColumns(widget);
556
+ const hidden = getHiddenColumnSet(widget);
557
+ return ordered.filter((name) => !hidden.has(name));
558
+ }
559
+
560
+ function withFieldSettings(config, patch) {
561
+ const current = isPlainConfigObject(config?.fieldSettings) ? config.fieldSettings : { hidden: [], order: [] };
562
+ return {
563
+ ...config,
564
+ fieldSettings: {
565
+ hidden: Array.isArray(current.hidden) ? [...current.hidden] : [],
566
+ order: Array.isArray(current.order) ? [...current.order] : [],
567
+ ...patch
568
+ }
569
+ };
570
+ }
571
+
572
+ function isPlainConfigObject(value) {
573
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
574
+ }
575
+
576
+ function reorderColumn(widget, fieldId, direction) {
577
+ const ordered = getOrderedColumns(widget);
578
+ const index = ordered.indexOf(fieldId);
579
+ if (index < 0) return widget.config?.fieldSettings;
580
+ const target = direction === "up" ? index - 1 : index + 1;
581
+ if (target < 0 || target >= ordered.length) return widget.config?.fieldSettings;
582
+ const next = [...ordered];
583
+ const [moved] = next.splice(index, 1);
584
+ next.splice(target, 0, moved);
585
+ return { ...(widget.config?.fieldSettings || {}), order: next };
586
+ }
587
+
588
+ function toggleColumnHidden(widget, fieldId) {
589
+ const hidden = getHiddenColumnSet(widget);
590
+ if (hidden.has(fieldId)) hidden.delete(fieldId);
591
+ else hidden.add(fieldId);
592
+ return { ...(widget.config?.fieldSettings || {}), hidden: Array.from(hidden) };
593
+ }
594
+
595
+ function getSortClauses(widget) {
596
+ return Array.isArray(widget?.config?.sort) ? widget.config.sort : [];
597
+ }
598
+
599
+ function getFilterConfig(widget) {
600
+ const filter = widget?.config?.filter;
601
+ if (!isPlainConfigObject(filter)) return { op: DEFAULT_FILTER_OP, clauses: [] };
602
+ return {
603
+ op: KNOWN_FILTER_CONJUNCTIONS.includes(filter.op) ? filter.op : DEFAULT_FILTER_OP,
604
+ clauses: Array.isArray(filter.clauses) ? filter.clauses : []
605
+ };
606
+ }
607
+
608
+ function getChartType(widget) {
609
+ const chartType = widget?.config?.chartType;
610
+ return KNOWN_CHART_TYPES.includes(chartType) ? chartType : DEFAULT_CHART_TYPE;
611
+ }
612
+
613
+ function getChartAxis(widget, axisKey) {
614
+ const axis = widget?.config?.[axisKey];
615
+ return isPlainConfigObject(axis) ? axis : {};
616
+ }
617
+
618
+ function getChartStyle(widget) {
619
+ const style = widget?.config?.style;
620
+ return isPlainConfigObject(style) ? style : {};
621
+ }
622
+
623
+ function summarizeSource(widget) {
624
+ const binding = widget?.config?.binding;
625
+ if (binding?.mode === "integration" && binding.source) return binding.source;
626
+ if (widget?.config?.source) return widget.config.source;
627
+ return "Static";
628
+ }
629
+
630
+ function summarizeFields(widget) {
631
+ const total = getColumnList(widget).length;
632
+ const hidden = getHiddenColumnSet(widget).size;
633
+ if (!total) return "0 shown";
634
+ return hidden ? `${total - hidden} of ${total} shown` : `${total} shown`;
635
+ }
636
+
637
+ function summarizeSort(widget) {
638
+ const sort = getSortClauses(widget);
639
+ if (!sort.length) return "โ€บ";
640
+ if (sort.length === 1) {
641
+ const [first] = sort;
642
+ return `${first.fieldId} ${first.direction || DEFAULT_SORT_DIRECTION}`;
643
+ }
644
+ return `${sort.length} sorts`;
645
+ }
646
+
647
+ function summarizeFilter(widget) {
648
+ const filter = getFilterConfig(widget);
649
+ const count = filter.clauses.length;
650
+ if (!count) return "โ€บ";
651
+ return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
652
+ }
653
+
654
+ function describeIntegrationLane(integration) {
655
+ return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
656
+ }
657
+
411
658
  const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
412
659
  ...normalizeWorkspaceTemplate(template),
413
660
  widgets: template.widgets
@@ -451,9 +698,36 @@ function TemplateGallery({
451
698
  onPreview,
452
699
  onClose,
453
700
  onApplyToCurrentTab,
454
- onCloneAsDashboard
701
+ onCloneAsDashboard,
702
+ filter,
703
+ onFilterChange
455
704
  }) {
705
+ const categories = useMemo(() => {
706
+ const set = new Set();
707
+ templates.forEach((template) => {
708
+ if (template.category) set.add(template.category);
709
+ });
710
+ return ["all", ...Array.from(set)];
711
+ }, [templates]);
712
+ const tags = useMemo(() => {
713
+ const set = new Set();
714
+ templates.forEach((template) => {
715
+ (template.tags || []).forEach((tag) => set.add(tag));
716
+ });
717
+ return ["all", ...Array.from(set)];
718
+ }, [templates]);
719
+ const filtered = useMemo(() => {
720
+ const query = (filter?.query || "").trim().toLowerCase();
721
+ return templates.filter((template) => {
722
+ if (filter?.category && filter.category !== "all" && template.category !== filter.category) return false;
723
+ if (filter?.tag && filter.tag !== "all" && !(template.tags || []).includes(filter.tag)) return false;
724
+ if (!query) return true;
725
+ const haystack = `${template.name} ${template.description} ${(template.tags || []).join(" ")} ${(template.bestFor || []).join(" ")}`.toLowerCase();
726
+ return haystack.includes(query);
727
+ });
728
+ }, [templates, filter]);
456
729
  const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
730
+ const setFilter = (patch) => onFilterChange({ ...(filter || {}), ...patch });
457
731
  return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
458
732
  <div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
459
733
  <section className="template-gallery-panel">
@@ -464,8 +738,31 @@ function TemplateGallery({
464
738
  </div>
465
739
  <button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
466
740
  </header>
741
+ <div className="template-gallery-filters">
742
+ <input
743
+ aria-label="Search templates"
744
+ placeholder="Search templatesโ€ฆ"
745
+ value={filter?.query || ""}
746
+ onChange={(event) => setFilter({ query: event.target.value })}
747
+ />
748
+ <select
749
+ aria-label="Filter by category"
750
+ value={filter?.category || "all"}
751
+ onChange={(event) => setFilter({ category: event.target.value })}
752
+ >
753
+ {categories.map((category) => <option key={category} value={category}>{category === "all" ? "All categories" : category}</option>)}
754
+ </select>
755
+ <select
756
+ aria-label="Filter by tag"
757
+ value={filter?.tag || "all"}
758
+ onChange={(event) => setFilter({ tag: event.target.value })}
759
+ >
760
+ {tags.map((tag) => <option key={tag} value={tag}>{tag === "all" ? "All tags" : `#${tag}`}</option>)}
761
+ </select>
762
+ </div>
467
763
  <div className="template-gallery-grid">
468
- {templates.map((template) => {
764
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No templates match those filters.</p> : null}
765
+ {filtered.map((template) => {
469
766
  const isPreviewing = previewTemplate?.id === template.id;
470
767
  return <article
471
768
  className={`template-card${isPreviewing ? " previewing" : ""}`}
@@ -502,10 +799,631 @@ function TemplateGallery({
502
799
  </div>;
503
800
  }
504
801
 
505
- function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart }) {
506
- const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
802
+ function SubPanelHeader({ title, breadcrumb, onBack }) {
803
+ return <div className="workspace-widget-subpanel-header">
804
+ <button type="button" className="workspace-widget-subpanel-back" aria-label={`Back from ${title}`} onClick={onBack}>โ€น</button>
805
+ <div>
806
+ {breadcrumb ? <p>{breadcrumb}</p> : null}
807
+ <strong>{title}</strong>
808
+ </div>
809
+ </div>;
810
+ }
811
+
812
+ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
813
+ const binding = widget.config?.binding || {};
814
+ const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
815
+ const [query, setQuery] = useState("");
816
+ const [laneFilter, setLaneFilter] = useState("all");
817
+ const groups = useMemo(() => {
818
+ const list = Array.isArray(integrations) ? integrations : [];
819
+ const filtered = list.filter((item) => {
820
+ if (laneFilter !== "all" && item.lane !== laneFilter) return false;
821
+ const text = `${item.label} ${item.provider} ${item.description}`.toLowerCase();
822
+ return !query.trim() || text.includes(query.trim().toLowerCase());
823
+ });
824
+ return {
825
+ "data-source": filtered.filter((item) => item.lane === "data-source"),
826
+ "workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
827
+ };
828
+ }, [integrations, laneFilter, query]);
829
+ const selectStatic = useCallback(() => {
830
+ if (widget.kind === "chart") {
831
+ onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
832
+ } else {
833
+ onChange({
834
+ ...widget.config,
835
+ source: widget.config?.source || "Companies",
836
+ binding: SAMPLE_DATA_BINDINGS.companiesManual
837
+ });
838
+ }
839
+ }, [onChange, widget.config, widget.kind]);
840
+ const selectIntegration = useCallback((integration) => {
841
+ onChange({
842
+ ...widget.config,
843
+ source: integration.label,
844
+ binding: {
845
+ mode: "integration",
846
+ source: integration.label,
847
+ integrationId: integration.id,
848
+ lane: integration.lane
849
+ }
850
+ });
851
+ }, [onChange, widget.config]);
852
+ return <section className="workspace-widget-subpanel">
853
+ <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
854
+ <div className="workspace-source-controls">
855
+ <label>
856
+ <Search size={14} aria-hidden="true" />
857
+ <input
858
+ aria-label="Search sources"
859
+ placeholder="Search sources"
860
+ value={query}
861
+ onChange={(event) => setQuery(event.target.value)}
862
+ />
863
+ </label>
864
+ <label>
865
+ <Database size={14} aria-hidden="true" />
866
+ <select
867
+ aria-label="Filter source type"
868
+ value={laneFilter}
869
+ onChange={(event) => setLaneFilter(event.target.value)}
870
+ >
871
+ <option value="all">All source types</option>
872
+ <option value="data-source">Data sources</option>
873
+ <option value="workspace-integration">Workspace tools</option>
874
+ </select>
875
+ <ChevronDown size={14} aria-hidden="true" />
876
+ </label>
877
+ </div>
878
+ <p className="workspace-panel-label">Static data</p>
879
+ <div className="workspace-source-list">
880
+ <button
881
+ type="button"
882
+ className={`workspace-source-row${currentMode !== "integration" ? " active" : ""}`}
883
+ onClick={selectStatic}
884
+ >
885
+ <span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
886
+ <span className="workspace-source-meta">
887
+ <strong>Static rows</strong>
888
+ <em>Inline data โ€” no external authority required.</em>
889
+ </span>
890
+ {currentMode !== "integration" ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
891
+ </button>
892
+ </div>
893
+ {Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
894
+ <p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
895
+ <div className="workspace-source-list">
896
+ {items.map((integration) => {
897
+ const isActive = currentMode === "integration" && binding.integrationId === integration.id;
898
+ const connected = integration.isConnected || integration.status === "connected";
899
+ return <button
900
+ key={integration.id}
901
+ type="button"
902
+ className={`workspace-source-row${isActive ? " active" : ""}`}
903
+ onClick={() => selectIntegration(integration)}
904
+ >
905
+ <span className="workspace-source-icon" aria-hidden="true">{integration.icon || integration.label?.[0] || "โ€ข"}</span>
906
+ <span className="workspace-source-meta">
907
+ <strong>{integration.label}</strong>
908
+ <em>{describeIntegrationLane(integration)} ยท {connected ? "connected" : "needs connection"}</em>
909
+ </span>
910
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
911
+ </button>;
912
+ })}
913
+ </div>
914
+ </div> : null)}
915
+ <p className="workspace-panel-hint">
916
+ Selecting a source writes a binding reference only. The browser does not query integrations or store tokens.
917
+ </p>
918
+ </section>;
919
+ }
920
+
921
+ function FieldsSubPanel({ widget, onChange, onBack }) {
922
+ const ordered = getOrderedColumns(widget);
923
+ const hidden = getHiddenColumnSet(widget);
924
+ const visible = ordered.filter((name) => !hidden.has(name));
925
+ const hiddenList = ordered.filter((name) => hidden.has(name));
926
+ const [hiddenOpen, setHiddenOpen] = useState(true);
927
+ const [draftField, setDraftField] = useState("");
928
+ const move = (fieldId, direction) => {
929
+ const next = reorderColumn(widget, fieldId, direction);
930
+ onChange({ ...widget.config, fieldSettings: next });
931
+ };
932
+ const toggle = (fieldId) => {
933
+ const next = toggleColumnHidden(widget, fieldId);
934
+ onChange({ ...widget.config, fieldSettings: next });
935
+ };
936
+ const removeColumn = (fieldId) => {
937
+ const nextColumns = ordered.filter((name) => name !== fieldId);
938
+ const fs = widget.config?.fieldSettings || {};
939
+ onChange({
940
+ ...widget.config,
941
+ columns: nextColumns,
942
+ fieldSettings: {
943
+ hidden: (fs.hidden || []).filter((name) => name !== fieldId),
944
+ order: (fs.order || []).filter((name) => name !== fieldId)
945
+ }
946
+ });
947
+ };
948
+ const addColumn = () => {
949
+ const trimmed = draftField.trim();
950
+ if (!trimmed || ordered.includes(trimmed)) return;
951
+ onChange({ ...widget.config, columns: [...ordered, trimmed] });
952
+ setDraftField("");
953
+ };
954
+ return <section className="workspace-widget-subpanel">
955
+ <SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
956
+ <p className="workspace-panel-label">Visible fields</p>
957
+ <div className="workspace-field-rows">
958
+ {visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
959
+ {visible.map((name, index) => <div key={name} className="workspace-field-row">
960
+ <span className="workspace-field-row-handle" aria-hidden="true">::</span>
961
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
962
+ <span className="workspace-field-row-name">{name}</span>
963
+ <span className="workspace-field-row-actions">
964
+ <button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>โ†‘</button>
965
+ <button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>โ†“</button>
966
+ <button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>๐Ÿ‘</button>
967
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โœ•</button>
968
+ </span>
969
+ </div>)}
970
+ </div>
971
+ <button
972
+ type="button"
973
+ className="workspace-hidden-fields-toggle"
974
+ onClick={() => setHiddenOpen((value) => !value)}
975
+ aria-expanded={hiddenOpen}
976
+ >
977
+ <span>๐Ÿ‘โ€๐Ÿ—จ Hidden Fields</span>
978
+ <span aria-hidden="true">{hiddenOpen ? "โˆ’" : "+"}</span>
979
+ </button>
980
+ {hiddenOpen ? <div className="workspace-field-rows workspace-hidden-fields">
981
+ {hiddenList.length === 0 ? <p className="workspace-panel-hint">No hidden fields.</p> : null}
982
+ {hiddenList.map((name) => <div key={name} className="workspace-field-row workspace-field-row-hidden">
983
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
984
+ <span className="workspace-field-row-name">{name}</span>
985
+ <span className="workspace-field-row-actions">
986
+ <button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>๐Ÿ‘</button>
987
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โœ•</button>
988
+ </span>
989
+ </div>)}
990
+ </div> : null}
991
+ <div className="workspace-field-add">
992
+ <input
993
+ aria-label="New field name"
994
+ value={draftField}
995
+ placeholder="Add fieldโ€ฆ"
996
+ onChange={(event) => setDraftField(event.target.value)}
997
+ onKeyDown={(event) => {
998
+ if (event.key === "Enter") {
999
+ event.preventDefault();
1000
+ addColumn();
1001
+ }
1002
+ }}
1003
+ />
1004
+ <button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
1005
+ </div>
1006
+ </section>;
1007
+ }
1008
+
1009
+ function SortSubPanel({ widget, onChange, onBack }) {
1010
+ const sort = getSortClauses(widget);
1011
+ const columns = getColumnList(widget);
1012
+ const updateSort = (next) => onChange({ ...widget.config, sort: next });
1013
+ const addClause = () => {
1014
+ const fieldId = columns[0] || "";
1015
+ if (!fieldId) return;
1016
+ updateSort([...sort, { fieldId, direction: DEFAULT_SORT_DIRECTION }]);
1017
+ };
1018
+ const updateClause = (index, patch) => {
1019
+ updateSort(sort.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause));
1020
+ };
1021
+ const removeClause = (index) => updateSort(sort.filter((_, idx) => idx !== index));
1022
+ return <section className="workspace-widget-subpanel">
1023
+ <SubPanelHeader title="Sorts" breadcrumb={widget.title} onBack={onBack} />
1024
+ <p className="workspace-panel-label">Sorts</p>
1025
+ <div className="workspace-sort-list">
1026
+ {sort.length === 0 ? <p className="workspace-panel-hint">No sorts applied.</p> : null}
1027
+ {sort.map((clause, index) => <div key={index} className="workspace-sort-row">
1028
+ <select
1029
+ aria-label={`Sort ${index + 1} field`}
1030
+ value={clause.fieldId}
1031
+ onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1032
+ >
1033
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1034
+ {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1035
+ </select>
1036
+ <select
1037
+ aria-label={`Sort ${index + 1} direction`}
1038
+ value={clause.direction || DEFAULT_SORT_DIRECTION}
1039
+ onChange={(event) => updateClause(index, { direction: event.target.value })}
1040
+ >
1041
+ {KNOWN_SORT_DIRECTIONS.map((dir) => <option key={dir} value={dir}>{dir === "asc" ? "Ascending" : "Descending"}</option>)}
1042
+ </select>
1043
+ <button type="button" aria-label={`Remove sort ${index + 1}`} onClick={() => removeClause(index)}>โœ•</button>
1044
+ </div>)}
1045
+ </div>
1046
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1047
+ + Add sort
1048
+ </button>
1049
+ <p className="workspace-panel-hint">
1050
+ Sort metadata persists with the widget. Live integrations are not queried from the browser.
1051
+ </p>
1052
+ </section>;
1053
+ }
1054
+
1055
+ function FilterSubPanel({ widget, onChange, onBack }) {
1056
+ const filter = getFilterConfig(widget);
1057
+ const columns = getColumnList(widget);
1058
+ const setFilter = (next) => onChange({ ...widget.config, filter: next });
1059
+ const setOp = (op) => setFilter({ ...filter, op });
1060
+ const addClause = () => {
1061
+ const fieldId = columns[0] || "";
1062
+ if (!fieldId) return;
1063
+ setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: DEFAULT_FILTER_OPERATOR, value: "" }] });
1064
+ };
1065
+ const updateClause = (index, patch) => {
1066
+ setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
1067
+ };
1068
+ const removeClause = (index) => {
1069
+ setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
1070
+ };
1071
+ return <section className="workspace-widget-subpanel">
1072
+ <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1073
+ <div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
1074
+ {KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
1075
+ key={op}
1076
+ type="button"
1077
+ role="radio"
1078
+ aria-checked={filter.op === op}
1079
+ className={filter.op === op ? "active" : ""}
1080
+ onClick={() => setOp(op)}
1081
+ >{op.toUpperCase()}</button>)}
1082
+ </div>
1083
+ <div className="workspace-filter-list">
1084
+ {filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
1085
+ {filter.clauses.map((clause, index) => {
1086
+ const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
1087
+ return <div key={index} className="workspace-filter-clause">
1088
+ <select
1089
+ aria-label={`Filter ${index + 1} field`}
1090
+ value={clause.fieldId}
1091
+ onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1092
+ >
1093
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1094
+ {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1095
+ </select>
1096
+ <select
1097
+ aria-label={`Filter ${index + 1} operator`}
1098
+ value={clause.operator || DEFAULT_FILTER_OPERATOR}
1099
+ onChange={(event) => updateClause(index, { operator: event.target.value })}
1100
+ >
1101
+ {KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
1102
+ </select>
1103
+ {!valueless ? <input
1104
+ aria-label={`Filter ${index + 1} value`}
1105
+ value={clause.value ?? ""}
1106
+ placeholder="value"
1107
+ onChange={(event) => updateClause(index, { value: event.target.value })}
1108
+ /> : <span className="workspace-filter-clause-empty">โ€”</span>}
1109
+ <button type="button" aria-label={`Remove filter ${index + 1}`} onClick={() => removeClause(index)}>โœ•</button>
1110
+ </div>;
1111
+ })}
1112
+ </div>
1113
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1114
+ + Add filter
1115
+ </button>
1116
+ <p className="workspace-panel-hint">
1117
+ Filter metadata persists with the widget. Live integration queries stay in the CLI / hosted layers.
1118
+ </p>
1119
+ </section>;
1120
+ }
1121
+
1122
+ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1123
+ const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
1124
+ const xAxis = getChartAxis(widget, "xAxis");
1125
+ const yAxis = getChartAxis(widget, "yAxis");
1126
+ const style = getChartStyle(widget);
1127
+ const setChartType = (type) => onChange({ ...widget.config, chartType: type });
1128
+ const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
1129
+ const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
1130
+ const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
1131
+ return <section className="workspace-chart-config">
1132
+ <p className="workspace-panel-label">Chart type</p>
1133
+ <div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
1134
+ {VISIBLE_CHART_TYPES.map((type) => {
1135
+ const TypeIcon = CHART_TYPE_ICONS[type];
1136
+ return <button
1137
+ key={type}
1138
+ type="button"
1139
+ role="tab"
1140
+ aria-selected={chartType === type}
1141
+ className={chartType === type ? "active" : ""}
1142
+ onClick={() => setChartType(type)}
1143
+ title={CHART_TYPE_LABELS[type]}
1144
+ >
1145
+ <IconGlyph icon={TypeIcon} size={17} />
1146
+ <em>{CHART_TYPE_LABELS[type]}</em>
1147
+ </button>;
1148
+ })}
1149
+ </div>
1150
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
1151
+ <span>Source</span><code>{summarizeSource(widget)}</code>
1152
+ </button>
1153
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
1154
+ <span>Filter</span><code>{summarizeFilter(widget)}</code>
1155
+ </button>
1156
+ <p className="workspace-panel-label">X axis</p>
1157
+ <label>
1158
+ <span>Data on display</span>
1159
+ <input
1160
+ value={xAxis.field || ""}
1161
+ placeholder="Stage"
1162
+ onChange={(event) => setXAxis({ field: event.target.value })}
1163
+ />
1164
+ </label>
1165
+ <label>
1166
+ <span>Sort by</span>
1167
+ <select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
1168
+ <option value="position">Stage position ascending</option>
1169
+ <option value="asc">Value ascending</option>
1170
+ <option value="desc">Value descending</option>
1171
+ </select>
1172
+ </label>
1173
+ <label className="workspace-toggle-row">
1174
+ <span>Omit zero values</span>
1175
+ <input
1176
+ type="checkbox"
1177
+ checked={Boolean(xAxis.omitZero)}
1178
+ onChange={(event) => setXAxis({ omitZero: event.target.checked })}
1179
+ />
1180
+ </label>
1181
+ <p className="workspace-panel-label">Y axis</p>
1182
+ <label>
1183
+ <span>Aggregation</span>
1184
+ <select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
1185
+ {KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
1186
+ </select>
1187
+ </label>
1188
+ <label>
1189
+ <span>Data on display</span>
1190
+ <input
1191
+ value={yAxis.field || ""}
1192
+ placeholder="Amount"
1193
+ onChange={(event) => setYAxis({ field: event.target.value })}
1194
+ />
1195
+ </label>
1196
+ <label>
1197
+ <span>Group by</span>
1198
+ <input
1199
+ value={yAxis.groupBy || ""}
1200
+ placeholder="โ€”"
1201
+ onChange={(event) => setYAxis({ groupBy: event.target.value })}
1202
+ />
1203
+ </label>
1204
+ <div className="workspace-axis-range">
1205
+ <label>
1206
+ <span>Min range</span>
1207
+ <input
1208
+ value={yAxis.min ?? ""}
1209
+ placeholder="Min"
1210
+ onChange={(event) => setYAxis({ min: event.target.value })}
1211
+ />
1212
+ </label>
1213
+ <label>
1214
+ <span>Max range</span>
1215
+ <input
1216
+ value={yAxis.max ?? ""}
1217
+ placeholder="Max"
1218
+ onChange={(event) => setYAxis({ max: event.target.value })}
1219
+ />
1220
+ </label>
1221
+ </div>
1222
+ <p className="workspace-panel-label">Style</p>
1223
+ <label>
1224
+ <span>Colors</span>
1225
+ <select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
1226
+ <option value="auto">Auto</option>
1227
+ <option value="accent">Accent</option>
1228
+ <option value="manual">Manual</option>
1229
+ </select>
1230
+ </label>
1231
+ {style.colors === "manual" ? <div className="workspace-color-picker-row">
1232
+ <label>
1233
+ <span>Manual color</span>
1234
+ <input
1235
+ type="color"
1236
+ value={style.manualColor || "#38bdf8"}
1237
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1238
+ />
1239
+ </label>
1240
+ <input
1241
+ aria-label="Manual color hex"
1242
+ value={style.manualColor || "#38bdf8"}
1243
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1244
+ />
1245
+ </div> : null}
1246
+ <label>
1247
+ <span>Axis name</span>
1248
+ <input
1249
+ value={style.axisName || ""}
1250
+ placeholder="None"
1251
+ onChange={(event) => setStyle({ axisName: event.target.value })}
1252
+ />
1253
+ </label>
1254
+ <label className="workspace-toggle-row">
1255
+ <span>Data labels</span>
1256
+ <input
1257
+ type="checkbox"
1258
+ checked={Boolean(style.dataLabels)}
1259
+ onChange={(event) => setStyle({ dataLabels: event.target.checked })}
1260
+ />
1261
+ </label>
1262
+ </section>;
1263
+ }
1264
+
1265
+ function CommandPalette({ commands, onClose }) {
1266
+ const [query, setQuery] = useState("");
1267
+ const inputRef = useRef(null);
1268
+ const [highlight, setHighlight] = useState(0);
1269
+ useEffect(() => {
1270
+ inputRef.current?.focus();
1271
+ }, []);
1272
+ const filtered = useMemo(() => {
1273
+ const trimmed = query.trim().toLowerCase();
1274
+ if (!trimmed) return commands;
1275
+ return commands.filter((command) => {
1276
+ const haystack = `${command.label} ${command.group || ""} ${(command.aliases || []).join(" ")}`.toLowerCase();
1277
+ return haystack.includes(trimmed);
1278
+ });
1279
+ }, [commands, query]);
1280
+ useEffect(() => {
1281
+ setHighlight((value) => Math.min(value, Math.max(0, filtered.length - 1)));
1282
+ }, [filtered.length]);
1283
+ const handleKeyDown = (event) => {
1284
+ if (event.key === "ArrowDown") {
1285
+ event.preventDefault();
1286
+ setHighlight((value) => Math.min(filtered.length - 1, value + 1));
1287
+ } else if (event.key === "ArrowUp") {
1288
+ event.preventDefault();
1289
+ setHighlight((value) => Math.max(0, value - 1));
1290
+ } else if (event.key === "Enter") {
1291
+ event.preventDefault();
1292
+ const command = filtered[highlight];
1293
+ if (command && !command.disabled) {
1294
+ command.run();
1295
+ onClose();
1296
+ }
1297
+ } else if (event.key === "Escape") {
1298
+ event.preventDefault();
1299
+ onClose();
1300
+ }
1301
+ };
1302
+ const groups = useMemo(() => {
1303
+ const map = new Map();
1304
+ filtered.forEach((command) => {
1305
+ const key = command.group || "General";
1306
+ if (!map.has(key)) map.set(key, []);
1307
+ map.get(key).push(command);
1308
+ });
1309
+ return Array.from(map.entries());
1310
+ }, [filtered]);
1311
+ return <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette">
1312
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1313
+ <section className="workspace-command-palette-panel" onKeyDown={handleKeyDown}>
1314
+ <header className="workspace-command-palette-input">
1315
+ <span aria-hidden="true">โŒ˜</span>
1316
+ <input
1317
+ ref={inputRef}
1318
+ value={query}
1319
+ onChange={(event) => setQuery(event.target.value)}
1320
+ placeholder="Type a commandโ€ฆ"
1321
+ aria-label="Command palette search"
1322
+ />
1323
+ <kbd>esc</kbd>
1324
+ </header>
1325
+ <div className="workspace-command-palette-list" role="listbox">
1326
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
1327
+ {groups.map(([group, items]) => <div key={group} className="workspace-command-palette-group">
1328
+ <p className="workspace-panel-label">{group}</p>
1329
+ {items.map((command) => {
1330
+ const globalIndex = filtered.indexOf(command);
1331
+ const isHighlighted = globalIndex === highlight;
1332
+ return <button
1333
+ key={command.id}
1334
+ type="button"
1335
+ role="option"
1336
+ aria-selected={isHighlighted}
1337
+ className={`workspace-command-palette-item${isHighlighted ? " active" : ""}${command.disabled ? " disabled" : ""}`}
1338
+ disabled={command.disabled}
1339
+ onMouseEnter={() => setHighlight(globalIndex)}
1340
+ onClick={() => {
1341
+ if (command.disabled) return;
1342
+ command.run();
1343
+ onClose();
1344
+ }}
1345
+ >
1346
+ <span aria-hidden="true">{typeof command.icon === "string" ? command.icon : <IconGlyph icon={command.icon} size={15} />}</span>
1347
+ <span className="workspace-command-palette-label">{command.label}</span>
1348
+ {command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
1349
+ </button>;
1350
+ })}
1351
+ </div>)}
1352
+ </div>
1353
+ <footer className="workspace-command-palette-footer">
1354
+ <span>โ†‘ โ†“ navigate</span>
1355
+ <span>โ†ต run</span>
1356
+ <span>esc close</span>
1357
+ </footer>
1358
+ </section>
1359
+ </div>;
1360
+ }
1361
+
1362
+ function RichTextEditor({ value, onChange }) {
1363
+ const textareaRef = useRef(null);
1364
+ const insert = useCallback((prefix, suffix = "", placeholder = "text") => {
1365
+ const textarea = textareaRef.current;
1366
+ const current = value || "";
1367
+ const start = textarea?.selectionStart ?? current.length;
1368
+ const end = textarea?.selectionEnd ?? current.length;
1369
+ const selected = current.slice(start, end) || placeholder;
1370
+ const next = `${current.slice(0, start)}${prefix}${selected}${suffix}${current.slice(end)}`;
1371
+ onChange(next);
1372
+ requestAnimationFrame(() => {
1373
+ textarea?.focus();
1374
+ const cursor = start + prefix.length + selected.length + suffix.length;
1375
+ textarea?.setSelectionRange(cursor, cursor);
1376
+ });
1377
+ }, [onChange, value]);
1378
+ return <div className="workspace-rich-text-editor">
1379
+ <div className="workspace-rich-text-toolbar" role="toolbar" aria-label="Rich text controls">
1380
+ <button type="button" aria-label="Heading" onClick={() => insert("## ", "", "Heading")}><Type size={14} /></button>
1381
+ <button type="button" aria-label="Bold" onClick={() => insert("**", "**")}><strong>B</strong></button>
1382
+ <button type="button" aria-label="Italic" onClick={() => insert("*", "*")}><Italic size={14} /></button>
1383
+ <button type="button" aria-label="Quote" onClick={() => insert("> ", "", "Quote")}><Quote size={14} /></button>
1384
+ <button type="button" aria-label="Bullet list" onClick={() => insert("- ", "", "List item")}><List size={14} /></button>
1385
+ <button type="button" aria-label="Link" onClick={() => insert("[", "](https://)", "Link")}><LinkIcon size={14} /></button>
1386
+ </div>
1387
+ <textarea
1388
+ ref={textareaRef}
1389
+ placeholder="Write text..."
1390
+ value={value || ""}
1391
+ onChange={(event) => onChange(event.target.value)}
1392
+ />
1393
+ </div>;
1394
+ }
1395
+
1396
+ function IframePreviewModal({ widget, onClose }) {
1397
+ const url = widget?.config?.url || "";
1398
+ const valid = isLikelyHttpUrl(url);
1399
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label={`${widget?.title || "iFrame"} expanded preview`}>
1400
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1401
+ <section className="workspace-iframe-modal">
1402
+ <header>
1403
+ <div>
1404
+ <p>iFrame preview</p>
1405
+ <h2>{widget?.title || "Untitled iFrame"}</h2>
1406
+ </div>
1407
+ <div>
1408
+ {valid ? <a href={url} target="_blank" rel="noreferrer"><ExternalLink size={15} /> Open</a> : null}
1409
+ <button type="button" aria-label="Close iFrame preview" onClick={onClose}><X size={16} /></button>
1410
+ </div>
1411
+ </header>
1412
+ {valid ? <iframe title={widget?.title || "iFrame preview"} src={url} /> : <div className="workspace-iframe-invalid">Enter a valid http(s) URL to preview this iFrame.</div>}
1413
+ </section>
1414
+ </div>;
1415
+ }
1416
+
1417
+ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1418
+ const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
1419
+ const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
1420
+ const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
507
1421
  const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
508
1422
  const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
1423
+ const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
1424
+ const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
1425
+ const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
1426
+ const chartColor = chartStyle.colors === "manual" && chartStyle.manualColor ? chartStyle.manualColor : undefined;
509
1427
  return <article
510
1428
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
511
1429
  onClick={onSelect}
@@ -528,7 +1446,7 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
528
1446
  onRemove();
529
1447
  }}
530
1448
  type="button"
531
- >x</button>
1449
+ ><X size={13} /></button>
532
1450
  </div>
533
1451
  {widget.kind === "view" ? <div
534
1452
  className="workspace-view-table"
@@ -542,11 +1460,23 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
542
1460
  <footer>Calculate</footer>
543
1461
  </div> : null}
544
1462
  {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
545
- {widget.config?.url ? <span>{widget.config.url}</span> : <span>Invalid URL</span>}
1463
+ {isLikelyHttpUrl(widget.config?.url) ? <iframe title={`${widget.title} preview`} src={widget.config.url} /> : <span>Enter a valid http(s) URL</span>}
1464
+ <button type="button" onClick={(event) => {
1465
+ event.stopPropagation();
1466
+ onExpandIframe(widget);
1467
+ }}><Maximize2 size={14} /> Expand</button>
546
1468
  </div> : null}
547
- {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
548
- {widget.kind === "chart" ? <div className="workspace-chart-preview">
549
- {chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
1469
+ {widget.kind === "rich-text" ? <div className="workspace-rich-text-preview" dangerouslySetInnerHTML={{ __html: richTextToHtml(widget.config?.text) }} /> : null}
1470
+ {widget.kind === "chart" ? <div className={`workspace-chart-preview kind-${chartType}`} data-data-labels={dataLabels ? "true" : "false"} style={chartColor ? { "--chart-accent": chartColor } : undefined}>
1471
+ {chartType === "sum" ? <strong className="workspace-chart-sum">{chartValues.reduce((acc, v) => acc + v, 0)}</strong> : null}
1472
+ {chartType === "gauge" ? <span className="workspace-chart-gauge" style={{ "--gauge-fill": `${Math.min(100, chartValues[chartValues.length - 1] || 0)}%` }} /> : null}
1473
+ {chartType === "pie" ? <span className="workspace-chart-pie" aria-hidden="true" /> : null}
1474
+ {chartType === "bar-horizontal" ? chartValues.map((height, index) => <span key={index} className="workspace-chart-bar-h" style={{ width: `${Math.max(5, Math.min(100, height))}%` }}>
1475
+ {dataLabels ? <em>{height}</em> : null}
1476
+ </span>) : null}
1477
+ {chartType === "bar-vertical" || !chartType ? chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }}>
1478
+ {dataLabels ? <em>{height}</em> : null}
1479
+ </span>) : null}
550
1480
  </div> : null}
551
1481
  {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
552
1482
  aria-label={`Resize ${widget.title} from ${corner} corner`}
@@ -767,6 +1697,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
767
1697
  const [resizeDrag, setResizeDrag] = useState(null);
768
1698
  const [moveDrag, setMoveDrag] = useState(null);
769
1699
  const [configMessage, setConfigMessage] = useState("");
1700
+ const [inspectorPath, setInspectorPath] = useState(SUB_PANEL_ROOT);
1701
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
1702
+ const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
1703
+ const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
770
1704
  const resizeDragRef = useRef(null);
771
1705
  const moveDragRef = useRef(null);
772
1706
  const importInputRef = useRef(null);
@@ -1332,8 +2266,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1332
2266
  }, []);
1333
2267
  const selectWidget = useCallback((widgetId) => {
1334
2268
  setSelectedWidgetId(widgetId);
2269
+ setInspectorPath(SUB_PANEL_ROOT);
1335
2270
  setPanelOpen(true);
1336
2271
  }, []);
2272
+ const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
2273
+ if (!selectedWidgetId) return;
2274
+ setConfig((prev) => {
2275
+ const prevTabs = getTabs(prev.canvas);
2276
+ const prevActiveId = getActiveTabId(prev.canvas);
2277
+ const nextTabs = prevTabs.map((tab) => {
2278
+ if (tab.id !== prevActiveId) return tab;
2279
+ return {
2280
+ ...tab,
2281
+ widgets: (tab.widgets || []).map((widget) =>
2282
+ widget.id === selectedWidgetId ? { ...widget, config: nextConfig } : widget
2283
+ )
2284
+ };
2285
+ });
2286
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
2287
+ });
2288
+ }, [activeDashboardId, selectedWidgetId]);
1337
2289
  const updateSelectedWidget = useCallback((updates) => {
1338
2290
  if (!selectedWidgetId) return;
1339
2291
  setConfig((prev) => {
@@ -1448,10 +2400,183 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1448
2400
  return () => window.removeEventListener("keydown", handler);
1449
2401
  }, [managementOpen, closeManagement]);
1450
2402
 
2403
+ useEffect(() => {
2404
+ const handler = (event) => {
2405
+ if (commandPaletteOpen) return;
2406
+ const target = event.target;
2407
+ const isEditable = target instanceof HTMLElement && (
2408
+ target.tagName === "INPUT" ||
2409
+ target.tagName === "TEXTAREA" ||
2410
+ target.isContentEditable
2411
+ );
2412
+ const meta = event.metaKey || event.ctrlKey;
2413
+ if (meta && (event.key === "k" || event.key === "K")) {
2414
+ event.preventDefault();
2415
+ setCommandPaletteOpen(true);
2416
+ return;
2417
+ }
2418
+ if (event.key === "/" && !isEditable && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2419
+ event.preventDefault();
2420
+ setCommandPaletteOpen(true);
2421
+ return;
2422
+ }
2423
+ if (!isEditable && workspaceView === "builder" && panelOpen && !commandPaletteOpen && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2424
+ const quickMap = { c: "chart", v: "view", i: "iframe", t: "rich-text" };
2425
+ const kind = quickMap[event.key.toLowerCase()];
2426
+ if (kind) {
2427
+ event.preventDefault();
2428
+ addWidget(kind);
2429
+ }
2430
+ }
2431
+ };
2432
+ window.addEventListener("keydown", handler);
2433
+ return () => window.removeEventListener("keydown", handler);
2434
+ }, [addWidget, commandPaletteOpen, managementOpen, panelOpen, settingsOpen, templateGalleryOpen, workspaceView]);
2435
+
1451
2436
  const builderStyle = workspaceView === "dashboards" || !panelOpen
1452
2437
  ? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
1453
2438
  : undefined;
1454
2439
 
2440
+ const closeCommandPalette = useCallback(() => setCommandPaletteOpen(false), []);
2441
+
2442
+ const paletteCommands = useMemo(() => {
2443
+ const list = [];
2444
+ list.push({
2445
+ id: "dashboard.new", group: "Dashboard", icon: Plus, label: "Create dashboard", shortcut: "N",
2446
+ run: () => addDashboard()
2447
+ });
2448
+ list.push({
2449
+ id: "dashboard.duplicate", group: "Dashboard", icon: Copy, label: "Duplicate dashboard",
2450
+ run: () => duplicateDashboard(),
2451
+ disabled: !activeDashboard
2452
+ });
2453
+ list.push({
2454
+ id: "dashboard.delete", group: "Dashboard", icon: Trash2, label: "Delete dashboard",
2455
+ disabled: !activeDashboard,
2456
+ run: () => {
2457
+ if (resolvedActiveDashboardIndex >= 0) deleteDashboard(resolvedActiveDashboardIndex);
2458
+ }
2459
+ });
2460
+ list.push({
2461
+ id: "dashboard.export", group: "Dashboard", icon: Download, label: "Export dashboard",
2462
+ run: () => exportConfig()
2463
+ });
2464
+ list.push({
2465
+ id: "dashboard.import", group: "Dashboard", icon: Import, label: "Import dashboards",
2466
+ run: () => importInputRef.current?.click()
2467
+ });
2468
+ list.push({
2469
+ id: "dashboard.templates", group: "Dashboard", icon: Grid2X2, label: "Open template gallery",
2470
+ run: () => setTemplateGalleryOpen(true)
2471
+ });
2472
+ list.push({
2473
+ id: "tab.new", group: "Tab", icon: Plus, label: "New tab",
2474
+ run: () => addTab()
2475
+ });
2476
+ list.push({
2477
+ id: "tab.duplicate", group: "Tab", icon: Copy, label: "Duplicate tab",
2478
+ run: () => duplicateTab()
2479
+ });
2480
+ [
2481
+ ["chart", "Add chart widget", "C"],
2482
+ ["view", "Add view widget", "V"],
2483
+ ["iframe", "Add iFrame widget", "I"],
2484
+ ["rich-text", "Add rich text widget", "T"]
2485
+ ].forEach(([kind, label, shortcut]) => {
2486
+ list.push({
2487
+ id: `widget.add.${kind}`,
2488
+ group: "Widget Add",
2489
+ icon: WIDGET_KIND_ICONS[kind],
2490
+ label,
2491
+ shortcut,
2492
+ disabled: workspaceView !== "builder",
2493
+ run: () => addWidget(kind)
2494
+ });
2495
+ });
2496
+ list.push({
2497
+ id: "widget.duplicate", group: "Widget", icon: Copy, label: "Duplicate selected widget",
2498
+ disabled: !selectedWidget,
2499
+ run: () => duplicateSelectedWidget()
2500
+ });
2501
+ list.push({
2502
+ id: "widget.remove", group: "Widget", icon: Trash2, label: "Remove selected widget",
2503
+ disabled: !selectedWidget,
2504
+ run: () => selectedWidget && removeSelectedWidget(selectedWidget.id)
2505
+ });
2506
+ list.push({
2507
+ id: "widget.source", group: "Widget", icon: Database, label: "Open widget source",
2508
+ disabled: !selectedWidget,
2509
+ run: () => {
2510
+ setPanelOpen(true);
2511
+ setInspectorPath("source");
2512
+ }
2513
+ });
2514
+ list.push({
2515
+ id: "widget.fields", group: "Widget", icon: Columns3, label: "Open widget fields",
2516
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2517
+ run: () => {
2518
+ setPanelOpen(true);
2519
+ setInspectorPath("fields");
2520
+ }
2521
+ });
2522
+ list.push({
2523
+ id: "widget.sort", group: "Widget", icon: SlidersHorizontal, label: "Open widget sorts",
2524
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2525
+ run: () => {
2526
+ setPanelOpen(true);
2527
+ setInspectorPath("sort");
2528
+ }
2529
+ });
2530
+ list.push({
2531
+ id: "widget.filter", group: "Widget", icon: Filter, label: "Open widget filter",
2532
+ disabled: !selectedWidget,
2533
+ run: () => {
2534
+ setPanelOpen(true);
2535
+ setInspectorPath("filter");
2536
+ }
2537
+ });
2538
+ list.push({
2539
+ id: "workspace.save", group: "Workspace", icon: Save, label: saving ? "Saving..." : "Save workspace",
2540
+ disabled: saving,
2541
+ shortcut: "S",
2542
+ run: () => save()
2543
+ });
2544
+ list.push({
2545
+ id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
2546
+ run: () => setSettingsOpen(true)
2547
+ });
2548
+ list.push({
2549
+ id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
2550
+ run: () => setManagementOpen(true)
2551
+ });
2552
+ list.push({
2553
+ id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
2554
+ run: () => showDashboardHome()
2555
+ });
2556
+ list.push({
2557
+ id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
2558
+ run: () => { window.location.href = "/settings/integrations"; }
2559
+ });
2560
+ return list;
2561
+ }, [
2562
+ activeDashboard,
2563
+ addWidget,
2564
+ addDashboard,
2565
+ addTab,
2566
+ deleteDashboard,
2567
+ duplicateDashboard,
2568
+ duplicateSelectedWidget,
2569
+ duplicateTab,
2570
+ exportConfig,
2571
+ removeSelectedWidget,
2572
+ resolvedActiveDashboardIndex,
2573
+ save,
2574
+ saving,
2575
+ selectedWidget,
2576
+ showDashboardHome,
2577
+ workspaceView
2578
+ ]);
2579
+
1455
2580
  return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
1456
2581
  <aside className="workspace-rail" aria-label="Workspace navigation">
1457
2582
  <div className="workspace-brand">
@@ -1482,12 +2607,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1482
2607
  </>}
1483
2608
  </div>
1484
2609
  <div className="workspace-toolbar-actions">
1485
- <button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
1486
- <button type="button" onClick={addDashboard}>New Dashboard</button>
1487
- <button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
1488
- <button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
1489
- <button type="button" onClick={exportConfig}>Export</button>
1490
- <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
2610
+ <button type="button" onClick={() => setTemplateGalleryOpen(true)}><Grid2X2 size={15} />Templates</button>
2611
+ <button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
2612
+ <button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
2613
+ <button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
2614
+ <button type="button" onClick={exportConfig}><Download size={15} />Export</button>
2615
+ <button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
1491
2616
  </div>
1492
2617
  <input
1493
2618
  ref={importInputRef}
@@ -1593,8 +2718,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1593
2718
  tabIndex={0}
1594
2719
  >x</span>
1595
2720
  </button>)}
1596
- <button type="button" onClick={addTab}>New Tab</button>
1597
- <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
2721
+ <button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
2722
+ <button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
1598
2723
  </div>
1599
2724
  <div
1600
2725
  className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
@@ -1648,6 +2773,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1648
2773
  onRemove={() => removeSelectedWidget(widget.id)}
1649
2774
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
1650
2775
  onSelect={() => selectWidget(widget.id)}
2776
+ onExpandIframe={setExpandedIframeWidget}
1651
2777
  selected={widget.id === selectedWidgetId}
1652
2778
  widget={widget}
1653
2779
  />)}
@@ -1662,6 +2788,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1662
2788
  onClose={closeTemplateGallery}
1663
2789
  onApplyToCurrentTab={applyTemplateToCurrentTab}
1664
2790
  onCloneAsDashboard={cloneTemplateAsDashboard}
2791
+ filter={templateFilter}
2792
+ onFilterChange={setTemplateFilter}
1665
2793
  /> : null}
1666
2794
 
1667
2795
  {settingsOpen ? <WorkspaceSettingsPanel
@@ -1686,15 +2814,41 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1686
2814
  <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
1687
2815
  {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
1688
2816
  </div>
1689
- {selectedWidget ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
1690
- <button type="button" onClick={duplicateSelectedWidget}>Duplicate</button>
1691
- <button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}>Remove</button>
2817
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
2818
+ <button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
2819
+ <button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
1692
2820
  </div> : null}
1693
- {selectedWidget ? <section className="workspace-widget-settings">
2821
+ {selectedWidget && inspectorPath === "source" ? <SourceSubPanel
2822
+ widget={selectedWidget}
2823
+ integrations={governedWorkspaceIntegrationCatalog}
2824
+ onChange={replaceSelectedWidgetConfig}
2825
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2826
+ /> : null}
2827
+ {selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
2828
+ widget={selectedWidget}
2829
+ onChange={replaceSelectedWidgetConfig}
2830
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2831
+ /> : null}
2832
+ {selectedWidget && inspectorPath === "sort" ? <SortSubPanel
2833
+ widget={selectedWidget}
2834
+ onChange={replaceSelectedWidgetConfig}
2835
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2836
+ /> : null}
2837
+ {selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
2838
+ widget={selectedWidget}
2839
+ onChange={replaceSelectedWidgetConfig}
2840
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2841
+ /> : null}
2842
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
1694
2843
  <label>
1695
2844
  <span>Title</span>
1696
2845
  <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
1697
2846
  </label>
2847
+ {selectedWidget.kind === "chart" ? <ChartConfigPanel
2848
+ widget={selectedWidget}
2849
+ onChange={replaceSelectedWidgetConfig}
2850
+ onSubPage={(name) => setInspectorPath(name)}
2851
+ /> : null}
1698
2852
  {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
1699
2853
  <label>
1700
2854
  <span>Sample Values</span>
@@ -1713,6 +2867,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1713
2867
  >
1714
2868
  <option value="json">Sample JSON</option>
1715
2869
  <option value="csv">Sample CSV</option>
2870
+ {selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
1716
2871
  </select>
1717
2872
  </label>
1718
2873
  </section> : null}
@@ -1733,30 +2888,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1733
2888
  </label> : null}
1734
2889
  {selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
1735
2890
  <span>Content</span>
1736
- <textarea
1737
- placeholder="Write text..."
2891
+ <RichTextEditor
1738
2892
  value={selectedWidget.config?.text || ""}
1739
- onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
2893
+ onChange={(text) => updateSelectedWidgetConfig({ text })}
1740
2894
  />
1741
2895
  <small className="workspace-field-hint">
1742
- {(selectedWidget.config?.text || "").length} characters ยท plain text only at V1
2896
+ {(selectedWidget.config?.text || "").length} characters ยท markdown controls
1743
2897
  </small>
1744
2898
  </label> : null}
1745
2899
  {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
1746
- <label>
1747
- <span>Source</span>
1748
- <input
1749
- value={selectedWidget.config?.source || ""}
1750
- onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
1751
- />
1752
- </label>
1753
- <label>
1754
- <span>Columns</span>
1755
- <input
1756
- value={serializeLineList(selectedWidget.config?.columns || [])}
1757
- onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
1758
- />
1759
- </label>
1760
2900
  <label>
1761
2901
  <span>Manual Rows</span>
1762
2902
  <textarea
@@ -1770,26 +2910,23 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1770
2910
  }}
1771
2911
  />
1772
2912
  </label>
1773
- <label>
1774
- <span>Static Binding</span>
1775
- <select
1776
- value={selectedWidget.config?.binding?.mode || "manual"}
1777
- onChange={(event) => {
1778
- const binding = event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.companiesManual;
1779
- updateSelectedWidgetConfig({ binding });
1780
- }}
1781
- >
1782
- <option value="manual">Manual Rows</option>
1783
- <option value="csv">Sample CSV</option>
1784
- </select>
1785
- </label>
1786
- <div className="workspace-settings-list">
1787
- <p className="workspace-panel-label">Settings</p>
1788
- <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
1789
- <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
1790
- <div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
1791
- <div><span>Filter</span><code>โ€บ</code></div>
1792
- <div><span>Sort</span><code>โ€บ</code></div>
2913
+ <div className="workspace-settings-list" role="group" aria-label="View widget settings">
2914
+ <p className="workspace-panel-label">Settings</p>
2915
+ <button type="button" className="workspace-settings-row" disabled>
2916
+ <span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code>
2917
+ </button>
2918
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("source")}>
2919
+ <span>Source</span><code>{summarizeSource(selectedWidget)}</code>
2920
+ </button>
2921
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
2922
+ <span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
2923
+ </button>
2924
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
2925
+ <span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
2926
+ </button>
2927
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
2928
+ <span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
2929
+ </button>
1793
2930
  </div>
1794
2931
  </section> : null}
1795
2932
  {selectedWidget.kind === "rich-text" ? <label>
@@ -1809,24 +2946,30 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1809
2946
  <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
1810
2947
  <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
1811
2948
  </div>
1812
- </section> : <section>
2949
+ </section> : null}
2950
+ {!selectedWidget ? <section>
1813
2951
  <div className="workspace-widget-empty">
1814
2952
  <strong>Pick a widget kind</strong>
1815
2953
  <p>
1816
2954
  Widgets snap to the 12-column ร— 16-row grid. {addSlot.w} ร— {addSlot.h} cells
1817
2955
  selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
1818
- canvas to reshape the placement.
2956
+ canvas to reshape the placement. Press <kbd>โŒ˜K</kbd> for the command palette.
1819
2957
  </p>
1820
2958
  </div>
1821
2959
  <p className="workspace-panel-label">Widget type</p>
1822
2960
  <div className="workspace-widget-types">
1823
- {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
1824
- <span>{widget.icon}</span>
2961
+ {widgetTypes.map((widget) => {
2962
+ const KindIcon = WIDGET_KIND_ICONS[widget.kind];
2963
+ const shortcut = widget.kind === "chart" ? "C" : widget.kind === "view" ? "V" : widget.kind === "iframe" ? "I" : "T";
2964
+ return <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
2965
+ <span><IconGlyph icon={KindIcon} size={15} /></span>
1825
2966
  {widget.label}
1826
- </button>)}
2967
+ <kbd>{shortcut}</kbd>
2968
+ </button>;
2969
+ })}
1827
2970
  </div>
1828
- </section>}
1829
- <section className="workspace-bindings" id="bindings">
2971
+ </section> : null}
2972
+ {inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-bindings" id="bindings">
1830
2973
  <p className="workspace-panel-label">Config bindings</p>
1831
2974
  {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
1832
2975
  <span>{key}</span>
@@ -1836,8 +2979,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1836
2979
  <span>integrationAdapter</span>
1837
2980
  <code>{adapterConfig.integrationAdapter}</code>
1838
2981
  </div>
1839
- </section>
2982
+ </section> : null}
1840
2983
  </aside> : null}
2984
+ {expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
2985
+ {commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
1841
2986
  </main>;
1842
2987
  }
1843
2988