@growthub/cli 0.9.9 → 0.9.11

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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/assets/worker-kits/creative-strategist-v1/kit.json +5 -2
  3. package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +4 -1
  4. package/assets/worker-kits/growthub-ai-website-cloner-v1/kit.json +6 -3
  5. package/assets/worker-kits/growthub-creative-video-pipeline-v1/kit.json +4 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +50 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +980 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +5 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +4 -5
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1686 -68
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +12 -16
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +61 -11
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +31 -1
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +236 -9
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +10 -64
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +5 -2
  19. package/assets/worker-kits/growthub-email-marketing-v1/kit.json +5 -2
  20. package/assets/worker-kits/growthub-geo-seo-v1/kit.json +5 -2
  21. package/assets/worker-kits/growthub-hyperframes-studio-v1/kit.json +5 -2
  22. package/assets/worker-kits/growthub-marketing-skills-v1/kit.json +6 -3
  23. package/assets/worker-kits/growthub-open-higgsfield-studio-v1/kit.json +5 -2
  24. package/assets/worker-kits/growthub-open-montage-studio-v1/kit.json +6 -3
  25. package/assets/worker-kits/growthub-postiz-social-v1/kit.json +5 -2
  26. package/assets/worker-kits/growthub-twenty-crm-v1/kit.json +6 -3
  27. package/assets/worker-kits/growthub-video-use-studio-v1/kit.json +5 -2
  28. package/assets/worker-kits/growthub-zernio-social-v1/kit.json +5 -2
  29. package/dist/index.js +1750 -433
  30. package/package.json +1 -1
@@ -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
+ Check,
9
+ ChevronDown,
10
+ Code2,
11
+ Columns3,
12
+ Copy,
13
+ Database,
14
+ Download,
15
+ ExternalLink,
16
+ FileText,
17
+ Filter,
18
+ Gauge,
19
+ Grid2X2,
20
+ Home,
21
+ Import,
22
+ Italic,
23
+ LayoutDashboard,
24
+ Link as LinkIcon,
25
+ List,
26
+ Maximize2,
27
+ Pencil,
28
+ PieChart,
29
+ Plus,
30
+ Quote,
31
+ Rows3,
32
+ Save,
33
+ Search,
34
+ Settings,
35
+ Sigma,
36
+ SlidersHorizontal,
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,79 @@ 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
+ const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
67
+ const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
68
+
69
+ const SOURCE_TYPE_OBJECTS = [
70
+ {
71
+ id: MANAGED_INTEGRATION_SOURCE_TYPE,
72
+ label: "Managed Integrations",
73
+ authority: "Growthub Bridge",
74
+ description: "Bridge or BYO adapters resolve metadata server-side."
75
+ },
76
+ {
77
+ id: CUSTOM_API_SOURCE_TYPE,
78
+ label: "Custom APIs/Webhooks",
79
+ authority: "Custom endpoint",
80
+ description: "Reference a governed endpoint object without storing credentials in widget config."
81
+ }
82
+ ];
83
+
84
+ const ENTITY_REFERENCE_FIELD_IDS = ["id", "entityId"];
85
+
86
+ const CHART_TYPE_LABELS = {
87
+ "bar-vertical": "Vertical Bar",
88
+ "bar-horizontal": "Horizontal Bar",
89
+ "pie": "Pie",
90
+ "sum": "Sum",
91
+ "gauge": "Gauge"
92
+ };
93
+
94
+ const CHART_TYPE_ICONS = {
95
+ "bar-vertical": BarChart3,
96
+ "bar-horizontal": Rows3,
97
+ "pie": PieChart,
98
+ "sum": Sigma,
99
+ "gauge": Gauge
100
+ };
101
+
102
+ const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
103
+
104
+ const WIDGET_KIND_ICONS = {
105
+ chart: BarChart3,
106
+ view: Table2,
107
+ iframe: Code2,
108
+ "rich-text": FileText
109
+ };
110
+
111
+ const FILTER_OPERATOR_LABELS = {
112
+ eq: "equals",
113
+ ne: "does not equal",
114
+ contains: "contains",
115
+ gt: "is greater than",
116
+ lt: "is less than",
117
+ isEmpty: "is empty",
118
+ isNotEmpty: "is not empty"
119
+ };
120
+
121
+ const COLUMN_ICON_FOR = (name) => {
122
+ const lower = String(name || "").toLowerCase();
123
+ if (lower.includes("name") || lower.includes("title")) return "🏛";
124
+ if (lower.includes("domain") || lower.includes("url") || lower.includes("link")) return "🔗";
125
+ if (lower.includes("address") || lower.includes("location")) return "🗺";
126
+ if (lower.includes("employee") || lower.includes("people") || lower.includes("user")) return "👥";
127
+ if (lower.includes("linkedin")) return "in";
128
+ if (lower.includes("twitter") || lower === "x") return "𝕏";
129
+ if (lower.includes("date") || lower.includes("created") || lower.includes("updated")) return "📅";
130
+ return "▦";
131
+ };
17
132
 
18
133
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
19
134
  const GRID_COLUMNS = 12;
@@ -350,6 +465,11 @@ function widgetKindLabel(kind) {
350
465
  return kind.charAt(0).toUpperCase() + kind.slice(1);
351
466
  }
352
467
 
468
+ function IconGlyph({ icon: Icon, size = 16 }) {
469
+ if (!Icon) return null;
470
+ return <Icon aria-hidden="true" size={size} strokeWidth={1.9} />;
471
+ }
472
+
353
473
  function isLikelyHttpUrl(value) {
354
474
  if (typeof value !== "string" || !value.trim()) return false;
355
475
  try {
@@ -364,6 +484,29 @@ function cloneConfig(value) {
364
484
  return JSON.parse(JSON.stringify(value));
365
485
  }
366
486
 
487
+ function escapeHtml(value) {
488
+ return String(value || "")
489
+ .replaceAll("&", "&amp;")
490
+ .replaceAll("<", "&lt;")
491
+ .replaceAll(">", "&gt;")
492
+ .replaceAll('"', "&quot;")
493
+ .replaceAll("'", "&#039;");
494
+ }
495
+
496
+ function richTextToHtml(value) {
497
+ const escaped = escapeHtml(value || "Start writing...");
498
+ return escaped
499
+ .replace(/^### (.*)$/gm, "<h3>$1</h3>")
500
+ .replace(/^## (.*)$/gm, "<h2>$1</h2>")
501
+ .replace(/^# (.*)$/gm, "<h1>$1</h1>")
502
+ .replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>")
503
+ .replace(/^- (.*)$/gm, "<li>$1</li>")
504
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
505
+ .replace(/\*(.*?)\*/g, "<em>$1</em>")
506
+ .replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
507
+ .replace(/\n/g, "<br />");
508
+ }
509
+
367
510
  function normalizeChartValues(value) {
368
511
  return String(value)
369
512
  .split(",")
@@ -408,6 +551,282 @@ function serializeManualRows(rows, columns) {
408
551
  .join("\n");
409
552
  }
410
553
 
554
+ function getColumnList(widget) {
555
+ return Array.isArray(widget?.config?.columns) ? widget.config.columns : [];
556
+ }
557
+
558
+ function getOrderedColumns(widget) {
559
+ const columns = getColumnList(widget);
560
+ const order = Array.isArray(widget?.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : [];
561
+ if (!order.length) return columns;
562
+ const known = new Set(columns);
563
+ const ordered = order.filter((name) => known.has(name));
564
+ const remaining = columns.filter((name) => !ordered.includes(name));
565
+ return [...ordered, ...remaining];
566
+ }
567
+
568
+ function getHiddenColumnSet(widget) {
569
+ const hidden = Array.isArray(widget?.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [];
570
+ return new Set(hidden);
571
+ }
572
+
573
+ function getVisibleColumns(widget) {
574
+ const ordered = getOrderedColumns(widget);
575
+ const hidden = getHiddenColumnSet(widget);
576
+ return ordered.filter((name) => !hidden.has(name));
577
+ }
578
+
579
+ function withFieldSettings(config, patch) {
580
+ const current = isPlainConfigObject(config?.fieldSettings) ? config.fieldSettings : { hidden: [], order: [] };
581
+ return {
582
+ ...config,
583
+ fieldSettings: {
584
+ hidden: Array.isArray(current.hidden) ? [...current.hidden] : [],
585
+ order: Array.isArray(current.order) ? [...current.order] : [],
586
+ ...patch
587
+ }
588
+ };
589
+ }
590
+
591
+ function isPlainConfigObject(value) {
592
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
593
+ }
594
+
595
+ function reorderColumn(widget, fieldId, direction) {
596
+ const ordered = getOrderedColumns(widget);
597
+ const index = ordered.indexOf(fieldId);
598
+ if (index < 0) return widget.config?.fieldSettings;
599
+ const target = direction === "up" ? index - 1 : index + 1;
600
+ if (target < 0 || target >= ordered.length) return widget.config?.fieldSettings;
601
+ const next = [...ordered];
602
+ const [moved] = next.splice(index, 1);
603
+ next.splice(target, 0, moved);
604
+ return { ...(widget.config?.fieldSettings || {}), order: next };
605
+ }
606
+
607
+ function toggleColumnHidden(widget, fieldId) {
608
+ const hidden = getHiddenColumnSet(widget);
609
+ if (hidden.has(fieldId)) hidden.delete(fieldId);
610
+ else hidden.add(fieldId);
611
+ return { ...(widget.config?.fieldSettings || {}), hidden: Array.from(hidden) };
612
+ }
613
+
614
+ function getSortClauses(widget) {
615
+ return Array.isArray(widget?.config?.sort) ? widget.config.sort : [];
616
+ }
617
+
618
+ function getFilterConfig(widget) {
619
+ const filter = widget?.config?.filter;
620
+ if (!isPlainConfigObject(filter)) return { op: DEFAULT_FILTER_OP, clauses: [] };
621
+ return {
622
+ op: KNOWN_FILTER_CONJUNCTIONS.includes(filter.op) ? filter.op : DEFAULT_FILTER_OP,
623
+ clauses: Array.isArray(filter.clauses) ? filter.clauses : []
624
+ };
625
+ }
626
+
627
+ function getChartType(widget) {
628
+ const chartType = widget?.config?.chartType;
629
+ return KNOWN_CHART_TYPES.includes(chartType) ? chartType : DEFAULT_CHART_TYPE;
630
+ }
631
+
632
+ function getChartAxis(widget, axisKey) {
633
+ const axis = widget?.config?.[axisKey];
634
+ return isPlainConfigObject(axis) ? axis : {};
635
+ }
636
+
637
+ function getChartStyle(widget) {
638
+ const style = widget?.config?.style;
639
+ return isPlainConfigObject(style) ? style : {};
640
+ }
641
+
642
+ function summarizeSource(widget) {
643
+ const binding = widget?.config?.binding;
644
+ if (binding?.mode === "integration") {
645
+ const source = binding.source || "Integration";
646
+ if (binding.entityLabel) return `${source} · ${binding.entityLabel}`;
647
+ if (binding.entityId) return `${source} · ${binding.entityId}`;
648
+ return source;
649
+ }
650
+ if (widget?.config?.source) return widget.config.source;
651
+ return "Static";
652
+ }
653
+
654
+ function summarizeSourceType(binding) {
655
+ if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
656
+ if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
657
+ return "Static data";
658
+ }
659
+
660
+ function resolveBindingSourceType(binding) {
661
+ if (binding?.sourceType) return binding.sourceType;
662
+ if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
663
+ return "static";
664
+ }
665
+
666
+ function summarizeFields(widget) {
667
+ const total = getColumnList(widget).length;
668
+ const hidden = getHiddenColumnSet(widget).size;
669
+ if (!total) return "0 shown";
670
+ return hidden ? `${total - hidden} of ${total} shown` : `${total} shown`;
671
+ }
672
+
673
+ function summarizeSort(widget) {
674
+ const sort = getSortClauses(widget);
675
+ if (!sort.length) return "›";
676
+ if (sort.length === 1) {
677
+ const [first] = sort;
678
+ return `${first.fieldId} ${first.direction || DEFAULT_SORT_DIRECTION}`;
679
+ }
680
+ return `${sort.length} sorts`;
681
+ }
682
+
683
+ function summarizeFilter(widget) {
684
+ const filter = getFilterConfig(widget);
685
+ const count = filter.clauses.length;
686
+ if (!count) return "›";
687
+ return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
688
+ }
689
+
690
+ function describeIntegrationLane(integration) {
691
+ return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
692
+ }
693
+
694
+ function flattenIntegrationSettings(integrationSettings) {
695
+ const grouped = integrationSettings?.integrations || integrationSettings || {};
696
+ const runtime = [
697
+ ...(Array.isArray(grouped.dataSources) ? grouped.dataSources : []),
698
+ ...(Array.isArray(grouped.workspaceIntegrations) ? grouped.workspaceIntegrations : [])
699
+ ];
700
+ const byId = new Map();
701
+ for (const item of [...governedWorkspaceIntegrationCatalog, ...runtime]) {
702
+ if (item?.id) byId.set(item.id, { ...byId.get(item.id), ...item });
703
+ }
704
+ return Array.from(byId.values());
705
+ }
706
+
707
+ function getFilterFieldOptions(widget, entities = []) {
708
+ const fields = new Set(getColumnList(widget));
709
+ const binding = widget?.config?.binding || {};
710
+ if (binding.mode === "integration") {
711
+ ["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"].forEach((field) => fields.add(field));
712
+ fields.add("provider");
713
+ fields.add("lane");
714
+ for (const entity of entities) {
715
+ if (entity?.metadata && typeof entity.metadata === "object") {
716
+ Object.keys(entity.metadata).forEach((field) => fields.add(field));
717
+ }
718
+ }
719
+ }
720
+ if (binding.sourceType === CUSTOM_API_SOURCE_TYPE) {
721
+ const customFields = Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"];
722
+ customFields.forEach((field) => fields.add(field));
723
+ }
724
+ return Array.from(fields).filter(Boolean);
725
+ }
726
+
727
+ function getEntityFieldValue(entity, fieldId) {
728
+ if (!entity || !fieldId) return "";
729
+ if (fieldId === "id" || fieldId === "entityId") return entity.id || "";
730
+ if (fieldId === "label" || fieldId === "name") return entity.label || "";
731
+ if (fieldId === "secondaryLabel") return entity.secondaryLabel || "";
732
+ if (fieldId === "entityType") return entity.entityType || "";
733
+ if (fieldId === "provider") return entity.provider || "";
734
+ if (fieldId === "lane") return entity.lane || "";
735
+ if (fieldId === "status") return entity.status || "";
736
+ if (entity.metadata && typeof entity.metadata === "object" && entity.metadata[fieldId] !== undefined) {
737
+ return String(entity.metadata[fieldId]);
738
+ }
739
+ return "";
740
+ }
741
+
742
+ function getEntityFieldChoices(entities) {
743
+ const fields = new Map();
744
+ const add = (id, label) => {
745
+ if (id && !fields.has(id)) fields.set(id, { id, label });
746
+ };
747
+ add("id", "Stable ID");
748
+ add("label", "Primary label");
749
+ add("secondaryLabel", "Secondary label");
750
+ add("entityType", "Entity type");
751
+ add("provider", "Provider");
752
+ add("lane", "Lane");
753
+ add("status", "Status");
754
+ for (const entity of entities) {
755
+ if (entity?.metadata && typeof entity.metadata === "object") {
756
+ Object.keys(entity.metadata).forEach((field) => add(field, field));
757
+ }
758
+ }
759
+ return Array.from(fields.values());
760
+ }
761
+
762
+ function getFilterFieldChoices(widget, entities = []) {
763
+ const binding = widget?.config?.binding || {};
764
+ if (binding.mode === "integration" && entities.length) return getEntityFieldChoices(entities);
765
+ return getFilterFieldOptions(widget, entities).map((id) => ({ id, label: id }));
766
+ }
767
+
768
+ function getEntityValueChoices(entities, fieldId) {
769
+ const seen = new Map();
770
+ for (const entity of entities) {
771
+ const value = getEntityFieldValue(entity, fieldId);
772
+ if (!value || seen.has(value)) continue;
773
+ const label = fieldId === "id" || fieldId === "entityId"
774
+ ? `${entity.label || value} · ${value}`
775
+ : value;
776
+ seen.set(value, { value, label, entity });
777
+ }
778
+ return Array.from(seen.values());
779
+ }
780
+
781
+ function findEntityByFieldValue(entities, fieldId, value) {
782
+ if (!value) return null;
783
+ return entities.find((entity) => getEntityFieldValue(entity, fieldId) === value) || null;
784
+ }
785
+
786
+ function updateWidgetEntityBinding(widget, entity) {
787
+ const binding = widget.config?.binding || {};
788
+ const existingFilter = widget.config?.filter;
789
+ const existingClauses = Array.isArray(existingFilter?.clauses) ? existingFilter.clauses : [];
790
+
791
+ if (!entity) {
792
+ const { entityId, entityType, entityLabel, ...restBinding } = binding;
793
+ const cleanedClauses = existingClauses.filter(
794
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
795
+ );
796
+ return {
797
+ ...widget.config,
798
+ binding: restBinding,
799
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: cleanedClauses }
800
+ };
801
+ }
802
+
803
+ const entityClause = { fieldId: "id", operator: "eq", value: entity.id };
804
+ const otherClauses = existingClauses.filter(
805
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
806
+ );
807
+ const nextBinding = {
808
+ ...binding,
809
+ entityId: entity.id,
810
+ entityLabel: entity.label
811
+ };
812
+ if (entity.entityType) nextBinding.entityType = entity.entityType;
813
+ else delete nextBinding.entityType;
814
+ return {
815
+ ...widget.config,
816
+ source: binding.source || entity.label,
817
+ binding: nextBinding,
818
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: [entityClause, ...otherClauses] }
819
+ };
820
+ }
821
+
822
+ function resolveChartColor(style, branding) {
823
+ if (style?.colors === "manual" && style.manualColor) return style.manualColor;
824
+ if (style?.colors === "brand-local") return branding?.accent || "#3f68ff";
825
+ if (style?.colors === "brand-bridge") return branding?.bridgeAccent || branding?.accent || "#3f68ff";
826
+ if (style?.colors === "accent") return "#38bdf8";
827
+ return null;
828
+ }
829
+
411
830
  const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
412
831
  ...normalizeWorkspaceTemplate(template),
413
832
  widgets: template.widgets
@@ -451,9 +870,36 @@ function TemplateGallery({
451
870
  onPreview,
452
871
  onClose,
453
872
  onApplyToCurrentTab,
454
- onCloneAsDashboard
873
+ onCloneAsDashboard,
874
+ filter,
875
+ onFilterChange
455
876
  }) {
877
+ const categories = useMemo(() => {
878
+ const set = new Set();
879
+ templates.forEach((template) => {
880
+ if (template.category) set.add(template.category);
881
+ });
882
+ return ["all", ...Array.from(set)];
883
+ }, [templates]);
884
+ const tags = useMemo(() => {
885
+ const set = new Set();
886
+ templates.forEach((template) => {
887
+ (template.tags || []).forEach((tag) => set.add(tag));
888
+ });
889
+ return ["all", ...Array.from(set)];
890
+ }, [templates]);
891
+ const filtered = useMemo(() => {
892
+ const query = (filter?.query || "").trim().toLowerCase();
893
+ return templates.filter((template) => {
894
+ if (filter?.category && filter.category !== "all" && template.category !== filter.category) return false;
895
+ if (filter?.tag && filter.tag !== "all" && !(template.tags || []).includes(filter.tag)) return false;
896
+ if (!query) return true;
897
+ const haystack = `${template.name} ${template.description} ${(template.tags || []).join(" ")} ${(template.bestFor || []).join(" ")}`.toLowerCase();
898
+ return haystack.includes(query);
899
+ });
900
+ }, [templates, filter]);
456
901
  const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
902
+ const setFilter = (patch) => onFilterChange({ ...(filter || {}), ...patch });
457
903
  return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
458
904
  <div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
459
905
  <section className="template-gallery-panel">
@@ -464,8 +910,31 @@ function TemplateGallery({
464
910
  </div>
465
911
  <button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
466
912
  </header>
913
+ <div className="template-gallery-filters">
914
+ <input
915
+ aria-label="Search templates"
916
+ placeholder="Search templates…"
917
+ value={filter?.query || ""}
918
+ onChange={(event) => setFilter({ query: event.target.value })}
919
+ />
920
+ <select
921
+ aria-label="Filter by category"
922
+ value={filter?.category || "all"}
923
+ onChange={(event) => setFilter({ category: event.target.value })}
924
+ >
925
+ {categories.map((category) => <option key={category} value={category}>{category === "all" ? "All categories" : category}</option>)}
926
+ </select>
927
+ <select
928
+ aria-label="Filter by tag"
929
+ value={filter?.tag || "all"}
930
+ onChange={(event) => setFilter({ tag: event.target.value })}
931
+ >
932
+ {tags.map((tag) => <option key={tag} value={tag}>{tag === "all" ? "All tags" : `#${tag}`}</option>)}
933
+ </select>
934
+ </div>
467
935
  <div className="template-gallery-grid">
468
- {templates.map((template) => {
936
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No templates match those filters.</p> : null}
937
+ {filtered.map((template) => {
469
938
  const isPreviewing = previewTemplate?.id === template.id;
470
939
  return <article
471
940
  className={`template-card${isPreviewing ? " previewing" : ""}`}
@@ -502,10 +971,923 @@ function TemplateGallery({
502
971
  </div>;
503
972
  }
504
973
 
505
- function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart }) {
506
- const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
974
+ function SubPanelHeader({ title, breadcrumb, onBack }) {
975
+ return <div className="workspace-widget-subpanel-header">
976
+ <button type="button" className="workspace-widget-subpanel-back" aria-label={`Back from ${title}`} onClick={onBack}>‹</button>
977
+ <div>
978
+ {breadcrumb ? <p>{breadcrumb}</p> : null}
979
+ <strong>{title}</strong>
980
+ </div>
981
+ </div>;
982
+ }
983
+
984
+ /**
985
+ * EntityBadge — chip showing the selected entity on the source panel and root inspector.
986
+ * Displays primary label + muted secondary label (stable ID). onClear is optional.
987
+ */
988
+ function EntityBadge({ entity, onClear }) {
989
+ const initials = entity.entityType
990
+ ? entity.entityType[0].toUpperCase()
991
+ : (entity.label?.[0] || "•").toUpperCase();
992
+ return <div className="workspace-entity-badge">
993
+ <span className="workspace-entity-badge-icon" aria-hidden="true">{initials}</span>
994
+ <span className="workspace-entity-badge-meta">
995
+ <strong title={entity.label}>{entity.label}</strong>
996
+ {entity.secondaryLabel ? <em title={entity.secondaryLabel}>{entity.secondaryLabel}</em> : null}
997
+ </span>
998
+ {onClear ? <button
999
+ type="button"
1000
+ className="workspace-entity-badge-clear"
1001
+ aria-label={`Clear selected entity ${entity.label}`}
1002
+ onClick={onClear}
1003
+ >
1004
+ <X size={11} />
1005
+ </button> : null}
1006
+ </div>;
1007
+ }
1008
+
1009
+ function UniversalSourceInfoCard() {
1010
+ return <p className="workspace-source-info-card">
1011
+ Universal source objects support managed integrations and custom APIs/webhooks through normalized metadata and stable saved references.
1012
+ </p>;
1013
+ }
1014
+
1015
+ /**
1016
+ * EntitySelector — compact dropdown for picking a normalized source object after
1017
+ * an integration is selected from the SourceSubPanel.
1018
+ *
1019
+ * Governed invariant: only the object `id` is persisted. The `label` is
1020
+ * display-only and may be refreshed from adapter metadata at any time.
1021
+ * The browser never holds source credentials or executes source queries.
1022
+ */
1023
+ function EntitySelector({ integration, entities, selectedEntityId, selectedEntityLabel, selectedEntityType, onSelect, loading }) {
1024
+ const selected = entities.find((e) => e.id === selectedEntityId)
1025
+ || (selectedEntityId ? {
1026
+ id: selectedEntityId,
1027
+ label: selectedEntityLabel || selectedEntityId,
1028
+ secondaryLabel: selectedEntityId,
1029
+ entityType: selectedEntityType
1030
+ } : null);
1031
+
1032
+ const clearSelected = () => {
1033
+ if (!selectedEntityId || window.confirm("Remove the selected source object from this widget?")) {
1034
+ onSelect(null);
1035
+ }
1036
+ };
1037
+
1038
+ return <div className="workspace-entity-selector">
1039
+ <p className="workspace-panel-label">Source object</p>
1040
+ {selected ? <EntityBadge entity={selected} onClear={clearSelected} /> : null}
1041
+ {loading ? <p className="workspace-entity-empty">Loading source objects…</p> : null}
1042
+ {!loading && !entities.length ? <p className="workspace-entity-empty">
1043
+ No source objects returned. Configure a server-side API/webhook object resolver for this integration.
1044
+ </p> : null}
1045
+ {!loading && entities.length ? <label className="workspace-entity-dropdown">
1046
+ <span>Select source object</span>
1047
+ <select
1048
+ aria-label="Select source object"
1049
+ value={selectedEntityId || ""}
1050
+ onChange={(event) => {
1051
+ const entity = entities.find((item) => item.id === event.target.value);
1052
+ onSelect(entity || null);
1053
+ }}
1054
+ >
1055
+ <option value="">Choose an object</option>
1056
+ {entities.map((entity) => <option key={entity.id} value={entity.id}>
1057
+ {entity.label}{entity.secondaryLabel ? ` · ${entity.secondaryLabel}` : ""}
1058
+ </option>)}
1059
+ </select>
1060
+ </label> : null}
1061
+ </div>;
1062
+ }
1063
+
1064
+ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
1065
+ const binding = widget.config?.binding || {};
1066
+ const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
1067
+ const activeSourceType = resolveBindingSourceType(binding);
1068
+ const [query, setQuery] = useState("");
1069
+ const [laneFilter, setLaneFilter] = useState("all");
1070
+ const hasConnectedSource = Boolean(
1071
+ binding.integrationId ||
1072
+ binding.endpointRef ||
1073
+ binding.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE ||
1074
+ binding.sourceType === CUSTOM_API_SOURCE_TYPE
1075
+ );
1076
+ const confirmSourceChange = useCallback((nextLabel) => {
1077
+ if (!hasConnectedSource) return true;
1078
+ const currentLabel = summarizeSource(widget);
1079
+ return window.confirm(`Change source from ${currentLabel} to ${nextLabel}? This updates the widget binding and can clear source-object filters.`);
1080
+ }, [hasConnectedSource, widget]);
1081
+
1082
+ const activeIntegration = useMemo(() => {
1083
+ if (currentMode !== "integration" || !binding.integrationId) return null;
1084
+ const list = Array.isArray(integrations) ? integrations : [];
1085
+ return list.find((item) => item.id === binding.integrationId) || null;
1086
+ }, [currentMode, binding.integrationId, integrations]);
1087
+
1088
+ const groups = useMemo(() => {
1089
+ const list = Array.isArray(integrations) ? integrations : [];
1090
+ const filtered = list.filter((item) => {
1091
+ if (laneFilter !== "all" && item.lane !== laneFilter) return false;
1092
+ const text = `${item.label} ${item.provider} ${item.description}`.toLowerCase();
1093
+ return !query.trim() || text.includes(query.trim().toLowerCase());
1094
+ });
1095
+ return {
1096
+ "data-source": filtered.filter((item) => item.lane === "data-source"),
1097
+ "workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
1098
+ };
1099
+ }, [integrations, laneFilter, query]);
1100
+
1101
+ const selectStatic = useCallback(() => {
1102
+ if (!confirmSourceChange("Static rows")) return;
1103
+ if (widget.kind === "chart") {
1104
+ onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
1105
+ } else {
1106
+ onChange({
1107
+ ...widget.config,
1108
+ source: widget.config?.source || "Companies",
1109
+ binding: SAMPLE_DATA_BINDINGS.companiesManual
1110
+ });
1111
+ }
1112
+ }, [confirmSourceChange, onChange, widget.config, widget.kind]);
1113
+
1114
+ const selectCustomApi = useCallback(() => {
1115
+ if (!confirmSourceChange("Custom APIs/Webhooks")) return;
1116
+ onChange({
1117
+ ...widget.config,
1118
+ source: "Custom APIs/Webhooks",
1119
+ binding: {
1120
+ ...binding,
1121
+ mode: "json",
1122
+ source: "Custom APIs/Webhooks",
1123
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1124
+ sourceAuthority: "custom-api",
1125
+ endpointRef: binding.endpointRef || "",
1126
+ fields: Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"]
1127
+ }
1128
+ });
1129
+ }, [binding, confirmSourceChange, onChange, widget.config]);
1130
+
1131
+ const updateCustomFields = useCallback((value) => {
1132
+ const fields = value.split(",").map((item) => item.trim()).filter(Boolean);
1133
+ onChange({
1134
+ ...widget.config,
1135
+ binding: {
1136
+ ...binding,
1137
+ mode: "json",
1138
+ source: "Custom APIs/Webhooks",
1139
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1140
+ sourceAuthority: "custom-api",
1141
+ fields
1142
+ }
1143
+ });
1144
+ }, [binding, onChange, widget.config]);
1145
+
1146
+ const updateEndpointRef = useCallback((value) => {
1147
+ onChange({
1148
+ ...widget.config,
1149
+ binding: {
1150
+ ...binding,
1151
+ mode: "json",
1152
+ source: "Custom APIs/Webhooks",
1153
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1154
+ sourceAuthority: "custom-api",
1155
+ endpointRef: value
1156
+ }
1157
+ });
1158
+ }, [binding, onChange, widget.config]);
1159
+
1160
+ const selectIntegration = useCallback((integration) => {
1161
+ if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
1162
+ onChange({
1163
+ ...widget.config,
1164
+ source: integration.label,
1165
+ binding: {
1166
+ mode: "integration",
1167
+ source: integration.label,
1168
+ sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
1169
+ sourceAuthority: "growthub-bridge",
1170
+ integrationId: integration.id,
1171
+ lane: integration.lane,
1172
+ provider: integration.provider
1173
+ }
1174
+ });
1175
+ }, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
1176
+
1177
+ return <section className="workspace-widget-subpanel">
1178
+ <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
1179
+ <UniversalSourceInfoCard />
1180
+ <p className="workspace-panel-label">Source type</p>
1181
+ <div className="workspace-source-object-list">
1182
+ {SOURCE_TYPE_OBJECTS.map((sourceType) => {
1183
+ const isActive = activeSourceType === sourceType.id;
1184
+ return <button
1185
+ key={sourceType.id}
1186
+ type="button"
1187
+ className={`workspace-source-object-row${isActive ? " active" : ""}`}
1188
+ onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
1189
+ disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
1190
+ >
1191
+ <span className="workspace-source-object-icon" aria-hidden="true">
1192
+ {sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
1193
+ </span>
1194
+ <span className="workspace-source-meta">
1195
+ <strong>{sourceType.label}</strong>
1196
+ <em>{sourceType.authority} · {sourceType.description}</em>
1197
+ </span>
1198
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1199
+ </button>;
1200
+ })}
1201
+ </div>
1202
+ <div className="workspace-source-controls">
1203
+ <label>
1204
+ <Search size={14} aria-hidden="true" />
1205
+ <input
1206
+ aria-label="Search sources"
1207
+ placeholder="Search connectors"
1208
+ value={query}
1209
+ onChange={(event) => setQuery(event.target.value)}
1210
+ />
1211
+ </label>
1212
+ <label>
1213
+ <Database size={14} aria-hidden="true" />
1214
+ <select
1215
+ aria-label="Filter source type"
1216
+ value={laneFilter}
1217
+ onChange={(event) => setLaneFilter(event.target.value)}
1218
+ >
1219
+ <option value="all">All connector lanes</option>
1220
+ <option value="data-source">Data sources</option>
1221
+ <option value="workspace-integration">Workspace tools</option>
1222
+ </select>
1223
+ <ChevronDown size={14} aria-hidden="true" />
1224
+ </label>
1225
+ </div>
1226
+ <p className="workspace-panel-label">Static data</p>
1227
+ <div className="workspace-source-list">
1228
+ <button
1229
+ type="button"
1230
+ className={`workspace-source-row${activeSourceType === "static" ? " active" : ""}`}
1231
+ onClick={selectStatic}
1232
+ >
1233
+ <span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
1234
+ <span className="workspace-source-meta">
1235
+ <strong>Static rows</strong>
1236
+ <em>Inline JSON, CSV, or manual rows remain supported.</em>
1237
+ </span>
1238
+ {activeSourceType === "static" ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1239
+ </button>
1240
+ </div>
1241
+ {Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
1242
+ <p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
1243
+ <div className="workspace-source-list">
1244
+ {items.map((integration) => {
1245
+ const isActive = currentMode === "integration" && binding.integrationId === integration.id;
1246
+ const connected = integration.isConnected || integration.status === "connected";
1247
+ return <button
1248
+ key={integration.id}
1249
+ type="button"
1250
+ className={`workspace-source-row${isActive ? " active" : ""}`}
1251
+ onClick={() => selectIntegration(integration)}
1252
+ >
1253
+ <span className="workspace-source-icon" aria-hidden="true">{integration.icon || integration.label?.[0] || "•"}</span>
1254
+ <span className="workspace-source-meta">
1255
+ <strong>{integration.label}</strong>
1256
+ <em>{describeIntegrationLane(integration)} · {connected ? "connected" : "needs connection"}</em>
1257
+ </span>
1258
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1259
+ </button>;
1260
+ })}
1261
+ </div>
1262
+ </div> : null)}
1263
+ {activeSourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-custom-source-config">
1264
+ <label>
1265
+ <span>Endpoint reference</span>
1266
+ <input
1267
+ value={binding.endpointRef || ""}
1268
+ placeholder="api.clients.primary"
1269
+ onChange={(event) => updateEndpointRef(event.target.value)}
1270
+ />
1271
+ </label>
1272
+ <label>
1273
+ <span>Available fields</span>
1274
+ <input
1275
+ value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
1276
+ placeholder="entityId, status, createdAt"
1277
+ onChange={(event) => updateCustomFields(event.target.value)}
1278
+ />
1279
+ </label>
1280
+ </div> : null}
1281
+ {currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
1282
+ <span>Active source</span>
1283
+ <strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
1284
+ <code>{binding.integrationId}</code>
1285
+ </div> : null}
1286
+ <p className="workspace-panel-hint">
1287
+ Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
1288
+ </p>
1289
+ </section>;
1290
+ }
1291
+
1292
+ function FieldsSubPanel({ widget, onChange, onBack }) {
1293
+ const ordered = getOrderedColumns(widget);
1294
+ const hidden = getHiddenColumnSet(widget);
1295
+ const visible = ordered.filter((name) => !hidden.has(name));
1296
+ const hiddenList = ordered.filter((name) => hidden.has(name));
1297
+ const [hiddenOpen, setHiddenOpen] = useState(true);
1298
+ const [draftField, setDraftField] = useState("");
1299
+ const move = (fieldId, direction) => {
1300
+ const next = reorderColumn(widget, fieldId, direction);
1301
+ onChange({ ...widget.config, fieldSettings: next });
1302
+ };
1303
+ const toggle = (fieldId) => {
1304
+ const next = toggleColumnHidden(widget, fieldId);
1305
+ onChange({ ...widget.config, fieldSettings: next });
1306
+ };
1307
+ const removeColumn = (fieldId) => {
1308
+ const nextColumns = ordered.filter((name) => name !== fieldId);
1309
+ const fs = widget.config?.fieldSettings || {};
1310
+ onChange({
1311
+ ...widget.config,
1312
+ columns: nextColumns,
1313
+ fieldSettings: {
1314
+ hidden: (fs.hidden || []).filter((name) => name !== fieldId),
1315
+ order: (fs.order || []).filter((name) => name !== fieldId)
1316
+ }
1317
+ });
1318
+ };
1319
+ const addColumn = () => {
1320
+ const trimmed = draftField.trim();
1321
+ if (!trimmed || ordered.includes(trimmed)) return;
1322
+ onChange({ ...widget.config, columns: [...ordered, trimmed] });
1323
+ setDraftField("");
1324
+ };
1325
+ return <section className="workspace-widget-subpanel">
1326
+ <SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
1327
+ <p className="workspace-panel-label">Visible fields</p>
1328
+ <div className="workspace-field-rows">
1329
+ {visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
1330
+ {visible.map((name, index) => <div key={name} className="workspace-field-row">
1331
+ <span className="workspace-field-row-handle" aria-hidden="true">::</span>
1332
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
1333
+ <span className="workspace-field-row-name">{name}</span>
1334
+ <span className="workspace-field-row-actions">
1335
+ <button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>↑</button>
1336
+ <button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>↓</button>
1337
+ <button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>👁</button>
1338
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
1339
+ </span>
1340
+ </div>)}
1341
+ </div>
1342
+ <button
1343
+ type="button"
1344
+ className="workspace-hidden-fields-toggle"
1345
+ onClick={() => setHiddenOpen((value) => !value)}
1346
+ aria-expanded={hiddenOpen}
1347
+ >
1348
+ <span>👁‍🗨 Hidden Fields</span>
1349
+ <span aria-hidden="true">{hiddenOpen ? "−" : "+"}</span>
1350
+ </button>
1351
+ {hiddenOpen ? <div className="workspace-field-rows workspace-hidden-fields">
1352
+ {hiddenList.length === 0 ? <p className="workspace-panel-hint">No hidden fields.</p> : null}
1353
+ {hiddenList.map((name) => <div key={name} className="workspace-field-row workspace-field-row-hidden">
1354
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
1355
+ <span className="workspace-field-row-name">{name}</span>
1356
+ <span className="workspace-field-row-actions">
1357
+ <button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>👁</button>
1358
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
1359
+ </span>
1360
+ </div>)}
1361
+ </div> : null}
1362
+ <div className="workspace-field-add">
1363
+ <input
1364
+ aria-label="New field name"
1365
+ value={draftField}
1366
+ placeholder="Add field…"
1367
+ onChange={(event) => setDraftField(event.target.value)}
1368
+ onKeyDown={(event) => {
1369
+ if (event.key === "Enter") {
1370
+ event.preventDefault();
1371
+ addColumn();
1372
+ }
1373
+ }}
1374
+ />
1375
+ <button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
1376
+ </div>
1377
+ </section>;
1378
+ }
1379
+
1380
+ function SortSubPanel({ widget, onChange, onBack }) {
1381
+ const sort = getSortClauses(widget);
1382
+ const columns = getColumnList(widget);
1383
+ const updateSort = (next) => onChange({ ...widget.config, sort: next });
1384
+ const addClause = () => {
1385
+ const fieldId = columns[0] || "";
1386
+ if (!fieldId) return;
1387
+ updateSort([...sort, { fieldId, direction: DEFAULT_SORT_DIRECTION }]);
1388
+ };
1389
+ const updateClause = (index, patch) => {
1390
+ updateSort(sort.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause));
1391
+ };
1392
+ const removeClause = (index) => updateSort(sort.filter((_, idx) => idx !== index));
1393
+ return <section className="workspace-widget-subpanel">
1394
+ <SubPanelHeader title="Sorts" breadcrumb={widget.title} onBack={onBack} />
1395
+ <p className="workspace-panel-label">Sorts</p>
1396
+ <div className="workspace-sort-list">
1397
+ {sort.length === 0 ? <p className="workspace-panel-hint">No sorts applied.</p> : null}
1398
+ {sort.map((clause, index) => <div key={index} className="workspace-sort-row">
1399
+ <select
1400
+ aria-label={`Sort ${index + 1} field`}
1401
+ value={clause.fieldId}
1402
+ onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1403
+ >
1404
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1405
+ {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1406
+ </select>
1407
+ <select
1408
+ aria-label={`Sort ${index + 1} direction`}
1409
+ value={clause.direction || DEFAULT_SORT_DIRECTION}
1410
+ onChange={(event) => updateClause(index, { direction: event.target.value })}
1411
+ >
1412
+ {KNOWN_SORT_DIRECTIONS.map((dir) => <option key={dir} value={dir}>{dir === "asc" ? "Ascending" : "Descending"}</option>)}
1413
+ </select>
1414
+ <button type="button" aria-label={`Remove sort ${index + 1}`} onClick={() => removeClause(index)}>✕</button>
1415
+ </div>)}
1416
+ </div>
1417
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1418
+ + Add sort
1419
+ </button>
1420
+ <p className="workspace-panel-hint">
1421
+ Sort metadata persists with the widget. Live integrations are not queried from the browser.
1422
+ </p>
1423
+ </section>;
1424
+ }
1425
+
1426
+ function FilterSubPanel({ widget, integrations, onChange, onBack }) {
1427
+ const binding = widget.config?.binding || {};
1428
+ const filter = getFilterConfig(widget);
1429
+ const [entities, setEntities] = useState([]);
1430
+ const [entitiesLoading, setEntitiesLoading] = useState(false);
1431
+ const fieldChoices = getFilterFieldChoices(widget, entities);
1432
+ const columns = fieldChoices.map((field) => field.id);
1433
+ const setFilter = (next) => onChange({ ...widget.config, filter: next });
1434
+ const setOp = (op) => setFilter({ ...filter, op });
1435
+ const activeIntegration = useMemo(() => {
1436
+ if (binding.mode !== "integration" || !binding.integrationId) return null;
1437
+ const list = Array.isArray(integrations) ? integrations : [];
1438
+ return list.find((item) => item.id === binding.integrationId) || null;
1439
+ }, [binding.integrationId, binding.mode, integrations]);
1440
+
1441
+ useEffect(() => {
1442
+ if (!binding.integrationId || binding.mode !== "integration") {
1443
+ setEntities([]);
1444
+ return;
1445
+ }
1446
+ let cancelled = false;
1447
+ setEntitiesLoading(true);
1448
+ fetch(`/api/workspace/integration-entities?integrationId=${encodeURIComponent(binding.integrationId)}`, { cache: "no-store" })
1449
+ .then((res) => res.ok ? res.json() : { entities: [] })
1450
+ .then((data) => {
1451
+ if (!cancelled) {
1452
+ setEntities(Array.isArray(data.entities) ? data.entities : []);
1453
+ setEntitiesLoading(false);
1454
+ }
1455
+ })
1456
+ .catch(() => {
1457
+ if (!cancelled) {
1458
+ setEntities([]);
1459
+ setEntitiesLoading(false);
1460
+ }
1461
+ });
1462
+ return () => { cancelled = true; };
1463
+ }, [binding.integrationId, binding.mode]);
1464
+
1465
+ const addClause = () => {
1466
+ const fieldId = binding.mode === "integration" && entities.length ? "id" : (columns[0] || "");
1467
+ if (!fieldId) return;
1468
+ setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: "eq", value: "" }] });
1469
+ };
1470
+ const updateField = (index, fieldId) => {
1471
+ setFilter({
1472
+ ...filter,
1473
+ clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, fieldId, value: "" } : clause)
1474
+ });
1475
+ };
1476
+ const selectEntity = useCallback((entity) => {
1477
+ onChange(updateWidgetEntityBinding(widget, entity));
1478
+ }, [onChange, widget]);
1479
+ const updateClause = (index, patch) => {
1480
+ setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
1481
+ };
1482
+ const removeClause = (index) => {
1483
+ setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
1484
+ };
1485
+ return <section className="workspace-widget-subpanel">
1486
+ <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1487
+ <UniversalSourceInfoCard />
1488
+ <div className="workspace-filter-source-state">
1489
+ <span>{summarizeSourceType(binding)}</span>
1490
+ <strong>{summarizeSource(widget)}</strong>
1491
+ </div>
1492
+ {binding.mode === "integration" && binding.integrationId ? <EntitySelector
1493
+ integration={activeIntegration}
1494
+ entities={entities}
1495
+ selectedEntityId={binding.entityId || null}
1496
+ selectedEntityLabel={binding.entityLabel || null}
1497
+ selectedEntityType={binding.entityType || null}
1498
+ onSelect={selectEntity}
1499
+ loading={entitiesLoading}
1500
+ /> : null}
1501
+ {binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
1502
+ <span>Custom endpoint</span>
1503
+ <code>{binding.endpointRef || "No endpoint reference set"}</code>
1504
+ </div> : null}
1505
+ <div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
1506
+ {KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
1507
+ key={op}
1508
+ type="button"
1509
+ role="radio"
1510
+ aria-checked={filter.op === op}
1511
+ className={filter.op === op ? "active" : ""}
1512
+ onClick={() => setOp(op)}
1513
+ >{op.toUpperCase()}</button>)}
1514
+ </div>
1515
+ <div className="workspace-filter-list">
1516
+ {filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
1517
+ {filter.clauses.map((clause, index) => {
1518
+ const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
1519
+ const valueChoices = binding.mode === "integration" ? getEntityValueChoices(entities, clause.fieldId) : [];
1520
+ return <div key={index} className="workspace-filter-clause">
1521
+ <select
1522
+ aria-label={`Filter ${index + 1} field`}
1523
+ value={clause.fieldId}
1524
+ onChange={(event) => updateField(index, event.target.value)}
1525
+ >
1526
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1527
+ {fieldChoices.map((field) => <option key={field.id} value={field.id}>{field.label}</option>)}
1528
+ </select>
1529
+ <select
1530
+ aria-label={`Filter ${index + 1} operator`}
1531
+ value={clause.operator || DEFAULT_FILTER_OPERATOR}
1532
+ onChange={(event) => updateClause(index, { operator: event.target.value })}
1533
+ >
1534
+ {KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
1535
+ </select>
1536
+ {!valueless && binding.mode === "integration" && valueChoices.length ? <select
1537
+ aria-label={`Filter ${index + 1} value`}
1538
+ value={clause.value ?? ""}
1539
+ onChange={(event) => {
1540
+ const entity = findEntityByFieldValue(entities, clause.fieldId, event.target.value);
1541
+ updateClause(index, { value: event.target.value });
1542
+ if (entity && (clause.fieldId === "id" || clause.fieldId === "entityId")) {
1543
+ onChange(updateWidgetEntityBinding(widget, entity));
1544
+ }
1545
+ }}
1546
+ >
1547
+ <option value="">Select value</option>
1548
+ {valueChoices.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
1549
+ </select> : !valueless ? <input
1550
+ aria-label={`Filter ${index + 1} value`}
1551
+ value={clause.value ?? ""}
1552
+ placeholder="value"
1553
+ onChange={(event) => updateClause(index, { value: event.target.value })}
1554
+ /> : <span className="workspace-filter-clause-empty">—</span>}
1555
+ <button type="button" aria-label={`Remove filter ${index + 1}`} onClick={() => removeClause(index)}>✕</button>
1556
+ </div>;
1557
+ })}
1558
+ </div>
1559
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1560
+ + Add filter
1561
+ </button>
1562
+ <p className="workspace-panel-hint">
1563
+ Filter metadata persists with the widget. Live integration queries stay in the CLI / hosted layers.
1564
+ </p>
1565
+ </section>;
1566
+ }
1567
+
1568
+ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
1569
+ const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
1570
+ const xAxis = getChartAxis(widget, "xAxis");
1571
+ const yAxis = getChartAxis(widget, "yAxis");
1572
+ const style = getChartStyle(widget);
1573
+ const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
1574
+ const setChartType = (type) => onChange({ ...widget.config, chartType: type });
1575
+ const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
1576
+ const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
1577
+ const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
1578
+ return <section className="workspace-chart-config">
1579
+ <p className="workspace-panel-label">Chart type</p>
1580
+ <div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
1581
+ {VISIBLE_CHART_TYPES.map((type) => {
1582
+ const TypeIcon = CHART_TYPE_ICONS[type];
1583
+ return <button
1584
+ key={type}
1585
+ type="button"
1586
+ role="tab"
1587
+ aria-selected={chartType === type}
1588
+ className={chartType === type ? "active" : ""}
1589
+ onClick={() => setChartType(type)}
1590
+ title={CHART_TYPE_LABELS[type]}
1591
+ >
1592
+ <IconGlyph icon={TypeIcon} size={17} />
1593
+ <em>{CHART_TYPE_LABELS[type]}</em>
1594
+ </button>;
1595
+ })}
1596
+ </div>
1597
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
1598
+ <span>Source</span><code>{summarizeSourceType(widget.config?.binding)} · {summarizeSource(widget)}</code>
1599
+ </button>
1600
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
1601
+ <span>Filter</span><code>{summarizeFilter(widget)}</code>
1602
+ </button>
1603
+ {widget.config?.binding?.entityId ? <EntityBadge entity={{
1604
+ id: widget.config.binding.entityId,
1605
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId,
1606
+ secondaryLabel: widget.config.binding.entityId,
1607
+ entityType: widget.config.binding.entityType
1608
+ }} /> : null}
1609
+ <p className="workspace-panel-label">X axis</p>
1610
+ <label>
1611
+ <span>Data on display</span>
1612
+ <input
1613
+ value={xAxis.field || ""}
1614
+ placeholder="Stage"
1615
+ onChange={(event) => setXAxis({ field: event.target.value })}
1616
+ />
1617
+ </label>
1618
+ <label>
1619
+ <span>Sort by</span>
1620
+ <select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
1621
+ <option value="position">Stage position ascending</option>
1622
+ <option value="asc">Value ascending</option>
1623
+ <option value="desc">Value descending</option>
1624
+ </select>
1625
+ </label>
1626
+ <label className="workspace-toggle-row">
1627
+ <span>Omit zero values</span>
1628
+ <input
1629
+ type="checkbox"
1630
+ checked={Boolean(xAxis.omitZero)}
1631
+ onChange={(event) => setXAxis({ omitZero: event.target.checked })}
1632
+ />
1633
+ </label>
1634
+ <p className="workspace-panel-label">Y axis</p>
1635
+ <label>
1636
+ <span>Aggregation</span>
1637
+ <select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
1638
+ {KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
1639
+ </select>
1640
+ </label>
1641
+ <label>
1642
+ <span>Data on display</span>
1643
+ <input
1644
+ value={yAxis.field || ""}
1645
+ placeholder="Amount"
1646
+ onChange={(event) => setYAxis({ field: event.target.value })}
1647
+ />
1648
+ </label>
1649
+ <label>
1650
+ <span>Group by</span>
1651
+ <input
1652
+ value={yAxis.groupBy || ""}
1653
+ placeholder="—"
1654
+ onChange={(event) => setYAxis({ groupBy: event.target.value })}
1655
+ />
1656
+ </label>
1657
+ <div className="workspace-axis-range">
1658
+ <label>
1659
+ <span>Min range</span>
1660
+ <input
1661
+ value={yAxis.min ?? ""}
1662
+ placeholder="Min"
1663
+ onChange={(event) => setYAxis({ min: event.target.value })}
1664
+ />
1665
+ </label>
1666
+ <label>
1667
+ <span>Max range</span>
1668
+ <input
1669
+ value={yAxis.max ?? ""}
1670
+ placeholder="Max"
1671
+ onChange={(event) => setYAxis({ max: event.target.value })}
1672
+ />
1673
+ </label>
1674
+ </div>
1675
+ <p className="workspace-panel-label">Style</p>
1676
+ <label>
1677
+ <span>Colors</span>
1678
+ <select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
1679
+ <option value="auto">Auto</option>
1680
+ <option value="accent">Accent</option>
1681
+ <option value="brand-local">Local brand kit</option>
1682
+ <option value="brand-bridge">Bridge brand kit</option>
1683
+ <option value="manual">Manual</option>
1684
+ </select>
1685
+ </label>
1686
+ <div className="workspace-color-preview-row">
1687
+ <span>Active color</span>
1688
+ <em style={{ background: activeColor }} />
1689
+ <code>{activeColor}</code>
1690
+ </div>
1691
+ {style.colors === "manual" ? <div className="workspace-color-picker-row">
1692
+ <label>
1693
+ <span>Manual color</span>
1694
+ <input
1695
+ type="color"
1696
+ value={style.manualColor || "#38bdf8"}
1697
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1698
+ />
1699
+ </label>
1700
+ <input
1701
+ aria-label="Manual color hex"
1702
+ value={style.manualColor || "#38bdf8"}
1703
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1704
+ />
1705
+ </div> : null}
1706
+ <label>
1707
+ <span>Axis name</span>
1708
+ <input
1709
+ value={style.axisName || ""}
1710
+ placeholder="None"
1711
+ onChange={(event) => setStyle({ axisName: event.target.value })}
1712
+ />
1713
+ </label>
1714
+ <label className="workspace-toggle-row">
1715
+ <span>Data labels</span>
1716
+ <input
1717
+ type="checkbox"
1718
+ checked={Boolean(style.dataLabels)}
1719
+ onChange={(event) => setStyle({ dataLabels: event.target.checked })}
1720
+ />
1721
+ </label>
1722
+ </section>;
1723
+ }
1724
+
1725
+ function CommandPalette({ commands, onClose }) {
1726
+ const [query, setQuery] = useState("");
1727
+ const inputRef = useRef(null);
1728
+ const [highlight, setHighlight] = useState(0);
1729
+ useEffect(() => {
1730
+ inputRef.current?.focus();
1731
+ }, []);
1732
+ const filtered = useMemo(() => {
1733
+ const trimmed = query.trim().toLowerCase();
1734
+ if (!trimmed) return commands;
1735
+ return commands.filter((command) => {
1736
+ const haystack = `${command.label} ${command.group || ""} ${(command.aliases || []).join(" ")}`.toLowerCase();
1737
+ return haystack.includes(trimmed);
1738
+ });
1739
+ }, [commands, query]);
1740
+ useEffect(() => {
1741
+ setHighlight((value) => Math.min(value, Math.max(0, filtered.length - 1)));
1742
+ }, [filtered.length]);
1743
+ const handleKeyDown = (event) => {
1744
+ if (event.key === "ArrowDown") {
1745
+ event.preventDefault();
1746
+ setHighlight((value) => Math.min(filtered.length - 1, value + 1));
1747
+ } else if (event.key === "ArrowUp") {
1748
+ event.preventDefault();
1749
+ setHighlight((value) => Math.max(0, value - 1));
1750
+ } else if (event.key === "Enter") {
1751
+ event.preventDefault();
1752
+ const command = filtered[highlight];
1753
+ if (command && !command.disabled) {
1754
+ command.run();
1755
+ onClose();
1756
+ }
1757
+ } else if (event.key === "Escape") {
1758
+ event.preventDefault();
1759
+ onClose();
1760
+ }
1761
+ };
1762
+ const groups = useMemo(() => {
1763
+ const map = new Map();
1764
+ filtered.forEach((command) => {
1765
+ const key = command.group || "General";
1766
+ if (!map.has(key)) map.set(key, []);
1767
+ map.get(key).push(command);
1768
+ });
1769
+ return Array.from(map.entries());
1770
+ }, [filtered]);
1771
+ return <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette">
1772
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1773
+ <section className="workspace-command-palette-panel" onKeyDown={handleKeyDown}>
1774
+ <header className="workspace-command-palette-input">
1775
+ <span aria-hidden="true">⌘</span>
1776
+ <input
1777
+ ref={inputRef}
1778
+ value={query}
1779
+ onChange={(event) => setQuery(event.target.value)}
1780
+ placeholder="Type a command…"
1781
+ aria-label="Command palette search"
1782
+ />
1783
+ <kbd>esc</kbd>
1784
+ </header>
1785
+ <div className="workspace-command-palette-list" role="listbox">
1786
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
1787
+ {groups.map(([group, items]) => <div key={group} className="workspace-command-palette-group">
1788
+ <p className="workspace-panel-label">{group}</p>
1789
+ {items.map((command) => {
1790
+ const globalIndex = filtered.indexOf(command);
1791
+ const isHighlighted = globalIndex === highlight;
1792
+ return <button
1793
+ key={command.id}
1794
+ type="button"
1795
+ role="option"
1796
+ aria-selected={isHighlighted}
1797
+ className={`workspace-command-palette-item${isHighlighted ? " active" : ""}${command.disabled ? " disabled" : ""}`}
1798
+ disabled={command.disabled}
1799
+ onMouseEnter={() => setHighlight(globalIndex)}
1800
+ onClick={() => {
1801
+ if (command.disabled) return;
1802
+ command.run();
1803
+ onClose();
1804
+ }}
1805
+ >
1806
+ <span aria-hidden="true">{typeof command.icon === "string" ? command.icon : <IconGlyph icon={command.icon} size={15} />}</span>
1807
+ <span className="workspace-command-palette-label">{command.label}</span>
1808
+ {command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
1809
+ </button>;
1810
+ })}
1811
+ </div>)}
1812
+ </div>
1813
+ <footer className="workspace-command-palette-footer">
1814
+ <span>↑ ↓ navigate</span>
1815
+ <span>↵ run</span>
1816
+ <span>esc close</span>
1817
+ </footer>
1818
+ </section>
1819
+ </div>;
1820
+ }
1821
+
1822
+ function RichTextEditor({ value, onChange }) {
1823
+ const textareaRef = useRef(null);
1824
+ const insert = useCallback((prefix, suffix = "", placeholder = "text") => {
1825
+ const textarea = textareaRef.current;
1826
+ const current = value || "";
1827
+ const start = textarea?.selectionStart ?? current.length;
1828
+ const end = textarea?.selectionEnd ?? current.length;
1829
+ const selected = current.slice(start, end) || placeholder;
1830
+ const next = `${current.slice(0, start)}${prefix}${selected}${suffix}${current.slice(end)}`;
1831
+ onChange(next);
1832
+ requestAnimationFrame(() => {
1833
+ textarea?.focus();
1834
+ const cursor = start + prefix.length + selected.length + suffix.length;
1835
+ textarea?.setSelectionRange(cursor, cursor);
1836
+ });
1837
+ }, [onChange, value]);
1838
+ return <div className="workspace-rich-text-editor">
1839
+ <div className="workspace-rich-text-toolbar" role="toolbar" aria-label="Rich text controls">
1840
+ <button type="button" aria-label="Heading" onClick={() => insert("## ", "", "Heading")}><Type size={14} /></button>
1841
+ <button type="button" aria-label="Bold" onClick={() => insert("**", "**")}><strong>B</strong></button>
1842
+ <button type="button" aria-label="Italic" onClick={() => insert("*", "*")}><Italic size={14} /></button>
1843
+ <button type="button" aria-label="Quote" onClick={() => insert("> ", "", "Quote")}><Quote size={14} /></button>
1844
+ <button type="button" aria-label="Bullet list" onClick={() => insert("- ", "", "List item")}><List size={14} /></button>
1845
+ <button type="button" aria-label="Link" onClick={() => insert("[", "](https://)", "Link")}><LinkIcon size={14} /></button>
1846
+ </div>
1847
+ <textarea
1848
+ ref={textareaRef}
1849
+ placeholder="Write text..."
1850
+ value={value || ""}
1851
+ onChange={(event) => onChange(event.target.value)}
1852
+ />
1853
+ </div>;
1854
+ }
1855
+
1856
+ function IframePreviewModal({ widget, onClose }) {
1857
+ const url = widget?.config?.url || "";
1858
+ const valid = isLikelyHttpUrl(url);
1859
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label={`${widget?.title || "iFrame"} expanded preview`}>
1860
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1861
+ <section className="workspace-iframe-modal">
1862
+ <header>
1863
+ <div>
1864
+ <p>iFrame preview</p>
1865
+ <h2>{widget?.title || "Untitled iFrame"}</h2>
1866
+ </div>
1867
+ <div>
1868
+ {valid ? <a href={url} target="_blank" rel="noreferrer"><ExternalLink size={15} /> Open</a> : null}
1869
+ <button type="button" aria-label="Close iFrame preview" onClick={onClose}><X size={16} /></button>
1870
+ </div>
1871
+ </header>
1872
+ {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>}
1873
+ </section>
1874
+ </div>;
1875
+ }
1876
+
1877
+ function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1878
+ const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
1879
+ const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
1880
+ const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
507
1881
  const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
508
1882
  const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
1883
+ const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
1884
+ const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
1885
+ const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
1886
+ const chartColor = resolveChartColor(chartStyle, branding);
1887
+ const selectedSourceObject = widget.config?.binding?.entityId ? {
1888
+ id: widget.config.binding.entityId,
1889
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId
1890
+ } : null;
509
1891
  return <article
510
1892
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
511
1893
  onClick={onSelect}
@@ -521,6 +1903,10 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
521
1903
  onPointerDown={(event) => onMoveStart(event)}
522
1904
  >::</span>
523
1905
  <strong>{widget.title}</strong>
1906
+ {selectedSourceObject ? <span
1907
+ className="workspace-widget-source-chip"
1908
+ title={`${selectedSourceObject.label} · ${selectedSourceObject.id}`}
1909
+ >{selectedSourceObject.label}</span> : null}
524
1910
  <button
525
1911
  aria-label={`Remove ${widget.title}`}
526
1912
  onClick={(event) => {
@@ -528,7 +1914,7 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
528
1914
  onRemove();
529
1915
  }}
530
1916
  type="button"
531
- >x</button>
1917
+ ><X size={13} /></button>
532
1918
  </div>
533
1919
  {widget.kind === "view" ? <div
534
1920
  className="workspace-view-table"
@@ -542,11 +1928,23 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
542
1928
  <footer>Calculate</footer>
543
1929
  </div> : null}
544
1930
  {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
545
- {widget.config?.url ? <span>{widget.config.url}</span> : <span>Invalid URL</span>}
1931
+ {isLikelyHttpUrl(widget.config?.url) ? <iframe title={`${widget.title} preview`} src={widget.config.url} /> : <span>Enter a valid http(s) URL</span>}
1932
+ <button type="button" onClick={(event) => {
1933
+ event.stopPropagation();
1934
+ onExpandIframe(widget);
1935
+ }}><Maximize2 size={14} /> Expand</button>
546
1936
  </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))}%` }} />)}
1937
+ {widget.kind === "rich-text" ? <div className="workspace-rich-text-preview" dangerouslySetInnerHTML={{ __html: richTextToHtml(widget.config?.text) }} /> : null}
1938
+ {widget.kind === "chart" ? <div className={`workspace-chart-preview kind-${chartType}`} data-data-labels={dataLabels ? "true" : "false"} style={chartColor ? { "--chart-accent": chartColor } : undefined}>
1939
+ {chartType === "sum" ? <strong className="workspace-chart-sum">{chartValues.reduce((acc, v) => acc + v, 0)}</strong> : null}
1940
+ {chartType === "gauge" ? <span className="workspace-chart-gauge" style={{ "--gauge-fill": `${Math.min(100, chartValues[chartValues.length - 1] || 0)}%` }} /> : null}
1941
+ {chartType === "pie" ? <span className="workspace-chart-pie" aria-hidden="true" /> : null}
1942
+ {chartType === "bar-horizontal" ? chartValues.map((height, index) => <span key={index} className="workspace-chart-bar-h" style={{ width: `${Math.max(5, Math.min(100, height))}%` }}>
1943
+ {dataLabels ? <em>{height}</em> : null}
1944
+ </span>) : null}
1945
+ {chartType === "bar-vertical" || !chartType ? chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }}>
1946
+ {dataLabels ? <em>{height}</em> : null}
1947
+ </span>) : null}
550
1948
  </div> : null}
551
1949
  {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
552
1950
  aria-label={`Resize ${widget.title} from ${corner} corner`}
@@ -721,7 +2119,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
721
2119
  </div>;
722
2120
  }
723
2121
 
724
- function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
2122
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
725
2123
  const [config, setConfig] = useState(() => {
726
2124
  const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
727
2125
  ? initialConfig.dashboards.map((dashboard, index) =>
@@ -767,11 +2165,17 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
767
2165
  const [resizeDrag, setResizeDrag] = useState(null);
768
2166
  const [moveDrag, setMoveDrag] = useState(null);
769
2167
  const [configMessage, setConfigMessage] = useState("");
2168
+ const [inspectorPath, setInspectorPath] = useState(SUB_PANEL_ROOT);
2169
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
2170
+ const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
2171
+ const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
770
2172
  const resizeDragRef = useRef(null);
771
2173
  const moveDragRef = useRef(null);
772
2174
  const importInputRef = useRef(null);
773
2175
  const addSlot = dragPreview || selectedPosition;
774
2176
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
2177
+ const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
2178
+ const branding = config.branding || {};
775
2179
  const occupiedCells = useMemo(() => {
776
2180
  const cells = new Set();
777
2181
  for (const widget of activeWidgets) {
@@ -1332,8 +2736,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1332
2736
  }, []);
1333
2737
  const selectWidget = useCallback((widgetId) => {
1334
2738
  setSelectedWidgetId(widgetId);
2739
+ setInspectorPath(SUB_PANEL_ROOT);
1335
2740
  setPanelOpen(true);
1336
2741
  }, []);
2742
+ const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
2743
+ if (!selectedWidgetId) return;
2744
+ setConfig((prev) => {
2745
+ const prevTabs = getTabs(prev.canvas);
2746
+ const prevActiveId = getActiveTabId(prev.canvas);
2747
+ const nextTabs = prevTabs.map((tab) => {
2748
+ if (tab.id !== prevActiveId) return tab;
2749
+ return {
2750
+ ...tab,
2751
+ widgets: (tab.widgets || []).map((widget) =>
2752
+ widget.id === selectedWidgetId ? { ...widget, config: nextConfig } : widget
2753
+ )
2754
+ };
2755
+ });
2756
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
2757
+ });
2758
+ }, [activeDashboardId, selectedWidgetId]);
1337
2759
  const updateSelectedWidget = useCallback((updates) => {
1338
2760
  if (!selectedWidgetId) return;
1339
2761
  setConfig((prev) => {
@@ -1448,10 +2870,183 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1448
2870
  return () => window.removeEventListener("keydown", handler);
1449
2871
  }, [managementOpen, closeManagement]);
1450
2872
 
2873
+ useEffect(() => {
2874
+ const handler = (event) => {
2875
+ if (commandPaletteOpen) return;
2876
+ const target = event.target;
2877
+ const isEditable = target instanceof HTMLElement && (
2878
+ target.tagName === "INPUT" ||
2879
+ target.tagName === "TEXTAREA" ||
2880
+ target.isContentEditable
2881
+ );
2882
+ const meta = event.metaKey || event.ctrlKey;
2883
+ if (meta && (event.key === "k" || event.key === "K")) {
2884
+ event.preventDefault();
2885
+ setCommandPaletteOpen(true);
2886
+ return;
2887
+ }
2888
+ if (event.key === "/" && !isEditable && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2889
+ event.preventDefault();
2890
+ setCommandPaletteOpen(true);
2891
+ return;
2892
+ }
2893
+ if (!isEditable && workspaceView === "builder" && panelOpen && !commandPaletteOpen && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2894
+ const quickMap = { c: "chart", v: "view", i: "iframe", t: "rich-text" };
2895
+ const kind = quickMap[event.key.toLowerCase()];
2896
+ if (kind) {
2897
+ event.preventDefault();
2898
+ addWidget(kind);
2899
+ }
2900
+ }
2901
+ };
2902
+ window.addEventListener("keydown", handler);
2903
+ return () => window.removeEventListener("keydown", handler);
2904
+ }, [addWidget, commandPaletteOpen, managementOpen, panelOpen, settingsOpen, templateGalleryOpen, workspaceView]);
2905
+
1451
2906
  const builderStyle = workspaceView === "dashboards" || !panelOpen
1452
2907
  ? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
1453
2908
  : undefined;
1454
2909
 
2910
+ const closeCommandPalette = useCallback(() => setCommandPaletteOpen(false), []);
2911
+
2912
+ const paletteCommands = useMemo(() => {
2913
+ const list = [];
2914
+ list.push({
2915
+ id: "dashboard.new", group: "Dashboard", icon: Plus, label: "Create dashboard", shortcut: "N",
2916
+ run: () => addDashboard()
2917
+ });
2918
+ list.push({
2919
+ id: "dashboard.duplicate", group: "Dashboard", icon: Copy, label: "Duplicate dashboard",
2920
+ run: () => duplicateDashboard(),
2921
+ disabled: !activeDashboard
2922
+ });
2923
+ list.push({
2924
+ id: "dashboard.delete", group: "Dashboard", icon: Trash2, label: "Delete dashboard",
2925
+ disabled: !activeDashboard,
2926
+ run: () => {
2927
+ if (resolvedActiveDashboardIndex >= 0) deleteDashboard(resolvedActiveDashboardIndex);
2928
+ }
2929
+ });
2930
+ list.push({
2931
+ id: "dashboard.export", group: "Dashboard", icon: Download, label: "Export dashboard",
2932
+ run: () => exportConfig()
2933
+ });
2934
+ list.push({
2935
+ id: "dashboard.import", group: "Dashboard", icon: Import, label: "Import dashboards",
2936
+ run: () => importInputRef.current?.click()
2937
+ });
2938
+ list.push({
2939
+ id: "dashboard.templates", group: "Dashboard", icon: Grid2X2, label: "Open template gallery",
2940
+ run: () => setTemplateGalleryOpen(true)
2941
+ });
2942
+ list.push({
2943
+ id: "tab.new", group: "Tab", icon: Plus, label: "New tab",
2944
+ run: () => addTab()
2945
+ });
2946
+ list.push({
2947
+ id: "tab.duplicate", group: "Tab", icon: Copy, label: "Duplicate tab",
2948
+ run: () => duplicateTab()
2949
+ });
2950
+ [
2951
+ ["chart", "Add chart widget", "C"],
2952
+ ["view", "Add view widget", "V"],
2953
+ ["iframe", "Add iFrame widget", "I"],
2954
+ ["rich-text", "Add rich text widget", "T"]
2955
+ ].forEach(([kind, label, shortcut]) => {
2956
+ list.push({
2957
+ id: `widget.add.${kind}`,
2958
+ group: "Widget Add",
2959
+ icon: WIDGET_KIND_ICONS[kind],
2960
+ label,
2961
+ shortcut,
2962
+ disabled: workspaceView !== "builder",
2963
+ run: () => addWidget(kind)
2964
+ });
2965
+ });
2966
+ list.push({
2967
+ id: "widget.duplicate", group: "Widget", icon: Copy, label: "Duplicate selected widget",
2968
+ disabled: !selectedWidget,
2969
+ run: () => duplicateSelectedWidget()
2970
+ });
2971
+ list.push({
2972
+ id: "widget.remove", group: "Widget", icon: Trash2, label: "Remove selected widget",
2973
+ disabled: !selectedWidget,
2974
+ run: () => selectedWidget && removeSelectedWidget(selectedWidget.id)
2975
+ });
2976
+ list.push({
2977
+ id: "widget.source", group: "Widget", icon: Database, label: "Open widget source",
2978
+ disabled: !selectedWidget,
2979
+ run: () => {
2980
+ setPanelOpen(true);
2981
+ setInspectorPath("source");
2982
+ }
2983
+ });
2984
+ list.push({
2985
+ id: "widget.fields", group: "Widget", icon: Columns3, label: "Open widget fields",
2986
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2987
+ run: () => {
2988
+ setPanelOpen(true);
2989
+ setInspectorPath("fields");
2990
+ }
2991
+ });
2992
+ list.push({
2993
+ id: "widget.sort", group: "Widget", icon: SlidersHorizontal, label: "Open widget sorts",
2994
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2995
+ run: () => {
2996
+ setPanelOpen(true);
2997
+ setInspectorPath("sort");
2998
+ }
2999
+ });
3000
+ list.push({
3001
+ id: "widget.filter", group: "Widget", icon: Filter, label: "Open widget filter",
3002
+ disabled: !selectedWidget,
3003
+ run: () => {
3004
+ setPanelOpen(true);
3005
+ setInspectorPath("filter");
3006
+ }
3007
+ });
3008
+ list.push({
3009
+ id: "workspace.save", group: "Workspace", icon: Save, label: saving ? "Saving..." : "Save workspace",
3010
+ disabled: saving,
3011
+ shortcut: "S",
3012
+ run: () => save()
3013
+ });
3014
+ list.push({
3015
+ id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
3016
+ run: () => setSettingsOpen(true)
3017
+ });
3018
+ list.push({
3019
+ id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
3020
+ run: () => setManagementOpen(true)
3021
+ });
3022
+ list.push({
3023
+ id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
3024
+ run: () => showDashboardHome()
3025
+ });
3026
+ list.push({
3027
+ id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
3028
+ run: () => { window.location.href = "/settings/integrations"; }
3029
+ });
3030
+ return list;
3031
+ }, [
3032
+ activeDashboard,
3033
+ addWidget,
3034
+ addDashboard,
3035
+ addTab,
3036
+ deleteDashboard,
3037
+ duplicateDashboard,
3038
+ duplicateSelectedWidget,
3039
+ duplicateTab,
3040
+ exportConfig,
3041
+ removeSelectedWidget,
3042
+ resolvedActiveDashboardIndex,
3043
+ save,
3044
+ saving,
3045
+ selectedWidget,
3046
+ showDashboardHome,
3047
+ workspaceView
3048
+ ]);
3049
+
1455
3050
  return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
1456
3051
  <aside className="workspace-rail" aria-label="Workspace navigation">
1457
3052
  <div className="workspace-brand">
@@ -1482,12 +3077,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1482
3077
  </>}
1483
3078
  </div>
1484
3079
  <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>
3080
+ <button type="button" onClick={() => setTemplateGalleryOpen(true)}><Grid2X2 size={15} />Templates</button>
3081
+ <button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
3082
+ <button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
3083
+ <button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
3084
+ <button type="button" onClick={exportConfig}><Download size={15} />Export</button>
3085
+ <button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
1491
3086
  </div>
1492
3087
  <input
1493
3088
  ref={importInputRef}
@@ -1593,8 +3188,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1593
3188
  tabIndex={0}
1594
3189
  >x</span>
1595
3190
  </button>)}
1596
- <button type="button" onClick={addTab}>New Tab</button>
1597
- <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
3191
+ <button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
3192
+ <button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
1598
3193
  </div>
1599
3194
  <div
1600
3195
  className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
@@ -1644,10 +3239,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1644
3239
  </button>
1645
3240
  {activeWidgets.map((widget) => <WidgetPreview
1646
3241
  key={widget.id}
3242
+ branding={branding}
1647
3243
  onMoveStart={(event) => beginMoveDrag(widget, event)}
1648
3244
  onRemove={() => removeSelectedWidget(widget.id)}
1649
3245
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
1650
3246
  onSelect={() => selectWidget(widget.id)}
3247
+ onExpandIframe={setExpandedIframeWidget}
1651
3248
  selected={widget.id === selectedWidgetId}
1652
3249
  widget={widget}
1653
3250
  />)}
@@ -1662,6 +3259,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1662
3259
  onClose={closeTemplateGallery}
1663
3260
  onApplyToCurrentTab={applyTemplateToCurrentTab}
1664
3261
  onCloneAsDashboard={cloneTemplateAsDashboard}
3262
+ filter={templateFilter}
3263
+ onFilterChange={setTemplateFilter}
1665
3264
  /> : null}
1666
3265
 
1667
3266
  {settingsOpen ? <WorkspaceSettingsPanel
@@ -1686,15 +3285,43 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1686
3285
  <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
1687
3286
  {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
1688
3287
  </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>
3288
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
3289
+ <button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
3290
+ <button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
1692
3291
  </div> : null}
1693
- {selectedWidget ? <section className="workspace-widget-settings">
3292
+ {selectedWidget && inspectorPath === "source" ? <SourceSubPanel
3293
+ widget={selectedWidget}
3294
+ integrations={availableIntegrations}
3295
+ onChange={replaceSelectedWidgetConfig}
3296
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
3297
+ /> : null}
3298
+ {selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
3299
+ widget={selectedWidget}
3300
+ onChange={replaceSelectedWidgetConfig}
3301
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
3302
+ /> : null}
3303
+ {selectedWidget && inspectorPath === "sort" ? <SortSubPanel
3304
+ widget={selectedWidget}
3305
+ onChange={replaceSelectedWidgetConfig}
3306
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
3307
+ /> : null}
3308
+ {selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
3309
+ widget={selectedWidget}
3310
+ integrations={availableIntegrations}
3311
+ onChange={replaceSelectedWidgetConfig}
3312
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
3313
+ /> : null}
3314
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
1694
3315
  <label>
1695
3316
  <span>Title</span>
1696
3317
  <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
1697
3318
  </label>
3319
+ {selectedWidget.kind === "chart" ? <ChartConfigPanel
3320
+ widget={selectedWidget}
3321
+ branding={branding}
3322
+ onChange={replaceSelectedWidgetConfig}
3323
+ onSubPage={(name) => setInspectorPath(name)}
3324
+ /> : null}
1698
3325
  {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
1699
3326
  <label>
1700
3327
  <span>Sample Values</span>
@@ -1713,6 +3340,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1713
3340
  >
1714
3341
  <option value="json">Sample JSON</option>
1715
3342
  <option value="csv">Sample CSV</option>
3343
+ {selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
1716
3344
  </select>
1717
3345
  </label>
1718
3346
  </section> : null}
@@ -1733,30 +3361,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1733
3361
  </label> : null}
1734
3362
  {selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
1735
3363
  <span>Content</span>
1736
- <textarea
1737
- placeholder="Write text..."
3364
+ <RichTextEditor
1738
3365
  value={selectedWidget.config?.text || ""}
1739
- onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
3366
+ onChange={(text) => updateSelectedWidgetConfig({ text })}
1740
3367
  />
1741
3368
  <small className="workspace-field-hint">
1742
- {(selectedWidget.config?.text || "").length} characters · plain text only at V1
3369
+ {(selectedWidget.config?.text || "").length} characters · markdown controls
1743
3370
  </small>
1744
3371
  </label> : null}
1745
3372
  {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
3373
  <label>
1761
3374
  <span>Manual Rows</span>
1762
3375
  <textarea
@@ -1770,26 +3383,23 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1770
3383
  }}
1771
3384
  />
1772
3385
  </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>
3386
+ <div className="workspace-settings-list" role="group" aria-label="View widget settings">
3387
+ <p className="workspace-panel-label">Settings</p>
3388
+ <button type="button" className="workspace-settings-row" disabled>
3389
+ <span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code>
3390
+ </button>
3391
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("source")}>
3392
+ <span>Source</span><code>{summarizeSource(selectedWidget)}</code>
3393
+ </button>
3394
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
3395
+ <span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
3396
+ </button>
3397
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
3398
+ <span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
3399
+ </button>
3400
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
3401
+ <span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
3402
+ </button>
1793
3403
  </div>
1794
3404
  </section> : null}
1795
3405
  {selectedWidget.kind === "rich-text" ? <label>
@@ -1809,24 +3419,30 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1809
3419
  <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
1810
3420
  <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
1811
3421
  </div>
1812
- </section> : <section>
3422
+ </section> : null}
3423
+ {!selectedWidget ? <section>
1813
3424
  <div className="workspace-widget-empty">
1814
3425
  <strong>Pick a widget kind</strong>
1815
3426
  <p>
1816
3427
  Widgets snap to the 12-column × 16-row grid. {addSlot.w} × {addSlot.h} cells
1817
3428
  selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
1818
- canvas to reshape the placement.
3429
+ canvas to reshape the placement. Press <kbd>⌘K</kbd> for the command palette.
1819
3430
  </p>
1820
3431
  </div>
1821
3432
  <p className="workspace-panel-label">Widget type</p>
1822
3433
  <div className="workspace-widget-types">
1823
- {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
1824
- <span>{widget.icon}</span>
3434
+ {widgetTypes.map((widget) => {
3435
+ const KindIcon = WIDGET_KIND_ICONS[widget.kind];
3436
+ const shortcut = widget.kind === "chart" ? "C" : widget.kind === "view" ? "V" : widget.kind === "iframe" ? "I" : "T";
3437
+ return <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
3438
+ <span><IconGlyph icon={KindIcon} size={15} /></span>
1825
3439
  {widget.label}
1826
- </button>)}
3440
+ <kbd>{shortcut}</kbd>
3441
+ </button>;
3442
+ })}
1827
3443
  </div>
1828
- </section>}
1829
- <section className="workspace-bindings" id="bindings">
3444
+ </section> : null}
3445
+ {inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-bindings" id="bindings">
1830
3446
  <p className="workspace-panel-label">Config bindings</p>
1831
3447
  {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
1832
3448
  <span>{key}</span>
@@ -1836,8 +3452,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1836
3452
  <span>integrationAdapter</span>
1837
3453
  <code>{adapterConfig.integrationAdapter}</code>
1838
3454
  </div>
1839
- </section>
3455
+ </section> : null}
1840
3456
  </aside> : null}
3457
+ {expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
3458
+ {commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
1841
3459
  </main>;
1842
3460
  }
1843
3461