@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +710 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1212 -67
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +182 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +10 -64
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- 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
|
+
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("&", "&")
|
|
471
|
+
.replaceAll("<", "<")
|
|
472
|
+
.replaceAll(">", ">")
|
|
473
|
+
.replaceAll('"', """)
|
|
474
|
+
.replaceAll("'", "'");
|
|
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
|
-
{
|
|
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
|
|
506
|
-
|
|
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
|
-
|
|
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 ? <
|
|
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" ? <
|
|
548
|
-
{widget.kind === "chart" ? <div className=
|
|
549
|
-
{chartValues.
|
|
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)}
|
|
1486
|
-
<button type="button" onClick={addDashboard}
|
|
1487
|
-
<button type="button" onClick={duplicateDashboard}
|
|
1488
|
-
<button type="button" onClick={() => importInputRef.current?.click()}
|
|
1489
|
-
<button type="button" onClick={exportConfig}
|
|
1490
|
-
<button type="button" onClick={save} disabled={saving}
|
|
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}
|
|
1597
|
-
<button type="button" onClick={duplicateTab}
|
|
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}
|
|
1691
|
-
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}
|
|
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
|
|
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
|
-
<
|
|
1737
|
-
placeholder="Write text..."
|
|
2891
|
+
<RichTextEditor
|
|
1738
2892
|
value={selectedWidget.config?.text || ""}
|
|
1739
|
-
onChange={(
|
|
2893
|
+
onChange={(text) => updateSelectedWidgetConfig({ text })}
|
|
1740
2894
|
/>
|
|
1741
2895
|
<small className="workspace-field-hint">
|
|
1742
|
-
{(selectedWidget.config?.text || "").length} characters ยท
|
|
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
|
-
<
|
|
1775
|
-
<
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
>
|
|
1782
|
-
<
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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> :
|
|
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) =>
|
|
1824
|
-
|
|
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
|
-
|
|
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
|
|