@lobb-js/lobb-ext-reports 0.4.1 → 0.6.0

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.
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, type Snippet } from "svelte";
3
+ import { GridStack } from "gridstack";
4
+ import "gridstack/dist/gridstack.min.css";
5
+
6
+ interface Props {
7
+ column?: number;
8
+ cellHeight?: string;
9
+ onLayoutChange?: (changes: { id: string; order: number; w: number; h: number }[]) => void;
10
+ children: Snippet;
11
+ }
12
+
13
+ let {
14
+ column = 12,
15
+ cellHeight = "150px",
16
+ onLayoutChange,
17
+ children,
18
+ }: Props = $props();
19
+
20
+ let gridEl: HTMLElement | undefined = $state();
21
+ let grid: GridStack | null = null;
22
+ let initialized = false;
23
+ let resizeObserver: ResizeObserver | null = null;
24
+
25
+ onMount(() => {
26
+ if (!gridEl) return;
27
+
28
+ grid = GridStack.init(
29
+ {
30
+ column,
31
+ cellHeight,
32
+ margin: "0.5rem",
33
+ float: false,
34
+ animate: true,
35
+ columnOpts: {
36
+ layout: "moveScale",
37
+ breakpointForWindow: false,
38
+ breakpoints: [
39
+ { w: 480, c: 1 },
40
+ ],
41
+ },
42
+ },
43
+ gridEl,
44
+ );
45
+
46
+ setTimeout(() => { initialized = true; }, 0);
47
+
48
+ grid.on("dragstop resizestop", () => {
49
+ grid?.compact();
50
+ });
51
+
52
+ grid.on("change", () => {
53
+ if (!onLayoutChange || !initialized) return;
54
+ const currentCols = grid!.getColumn();
55
+ if (currentCols === 1) return;
56
+ const sorted = (grid!.engine.nodes as any[])
57
+ .filter((n) => {
58
+ const id = n.el?.getAttribute("data-gs-id");
59
+ return id != null && id !== "null";
60
+ })
61
+ .sort((a, b) => (a.y !== b.y ? a.y - b.y : a.x - b.x))
62
+ .map((node, index) => ({
63
+ id: node.el!.getAttribute("data-gs-id") as string,
64
+ order: index,
65
+ w: Math.round(node.w * (column / currentCols)),
66
+ h: node.h,
67
+ }));
68
+ if (sorted.length > 0) onLayoutChange(sorted);
69
+ });
70
+
71
+ resizeObserver = new ResizeObserver((entries) => {
72
+ window.dispatchEvent(new Event("resize"));
73
+ const width = entries[0]?.contentRect.width ?? gridEl!.offsetWidth;
74
+ if (width <= 480) {
75
+ grid?.disable();
76
+ } else {
77
+ grid?.enable();
78
+ }
79
+ });
80
+ resizeObserver.observe(gridEl);
81
+ });
82
+
83
+ onDestroy(() => {
84
+ resizeObserver?.disconnect();
85
+ resizeObserver = null;
86
+ grid?.destroy(false);
87
+ grid = null;
88
+ });
89
+ </script>
90
+
91
+ <div class="grid-stack" bind:this={gridEl}>
92
+ {@render children()}
93
+ </div>
94
+
95
+ <style>
96
+ :global(.grid-stack-item-content) {
97
+ inset: 0;
98
+ position: absolute;
99
+ }
100
+
101
+ :global(.grid-stack) {
102
+ background: transparent;
103
+ padding: 0.5rem;
104
+ }
105
+
106
+ :global(.grid-stack-placeholder > .placeholder-content) {
107
+ position: absolute !important;
108
+ width: auto !important;
109
+ border: 2px dashed color-mix(in oklab, var(--muted-foreground) 25%, transparent) !important;
110
+ border-radius: 0.375rem !important;
111
+ background-color: rgba(0, 0, 0, 0.06) !important;
112
+ }
113
+
114
+ :global(.dark .grid-stack-placeholder > .placeholder-content) {
115
+ background-color: rgba(255, 255, 255, 0.06) !important;
116
+ }
117
+ </style>
@@ -0,0 +1,15 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ import "gridstack/dist/gridstack.min.css";
3
+ declare const __propDef: {
4
+ props: Record<string, never>;
5
+ events: {
6
+ [evt: string]: CustomEvent<any>;
7
+ };
8
+ slots: {};
9
+ };
10
+ export type GridStackProps = typeof __propDef.props;
11
+ export type GridStackEvents = typeof __propDef.events;
12
+ export type GridStackSlots = typeof __propDef.slots;
13
+ export default class GridStack extends SvelteComponentTyped<GridStackProps, GridStackEvents, GridStackSlots> {
14
+ }
15
+ export {};
@@ -2,25 +2,33 @@
2
2
  import type { ExtensionProps } from "@lobb-js/studio";
3
3
  import Table from "./charts/table.svelte";
4
4
  import ChartJs from "./charts/chartJs.svelte";
5
+ import Metric from "./charts/metric.svelte";
5
6
 
6
7
  interface Props extends ExtensionProps {
7
8
  chartRecord: any;
8
9
  onChartDeleted?: () => Promise<void>;
9
10
  onChartEdited?: () => Promise<void>;
10
- context?: Record<string, any>;
11
11
  }
12
12
 
13
13
  const {
14
14
  chartRecord,
15
15
  onChartDeleted,
16
16
  onChartEdited,
17
- context,
18
17
  ...props
19
18
  }: Props = $props();
20
19
 
21
20
  const utils = props.utils;
22
21
  const { UpdateDetailViewButton, Button } = utils.components;
23
22
  const icons = utils.components.Icons;
23
+
24
+ function findCustomChartComponent(type: string): any {
25
+ const extensions = utils.ctx?.extensions ?? {};
26
+ for (const ext of Object.values(extensions) as any[]) {
27
+ const comp = ext.components?.[`reports.chart.${type}`];
28
+ if (comp) return comp;
29
+ }
30
+ return null;
31
+ }
24
32
  let dataPromise = $state(getChartData());
25
33
 
26
34
  async function getChartData() {
@@ -29,7 +37,6 @@
29
37
  chartRecord.id,
30
38
  {
31
39
  action: "run_query",
32
- context: JSON.stringify(context),
33
40
  },
34
41
  );
35
42
 
@@ -57,7 +64,7 @@
57
64
  }
58
65
  </script>
59
66
 
60
- <div class="gridContainer h-96 rounded-md border bg-background">
67
+ <div class="gridContainer h-full rounded-md border bg-background">
61
68
  <div class="flex items-center justify-between border-b p-2 text-sm">
62
69
  <div>{chartRecord.title}</div>
63
70
  <div class="flex">
@@ -78,7 +85,7 @@
78
85
  ></Button>
79
86
  </div>
80
87
  </div>
81
- <div class="flex items-center justify-center overflow-auto">
88
+ <div class="flex min-h-0 items-center justify-center overflow-hidden">
82
89
  <svelte:boundary>
83
90
  {#snippet failed(error, reset)}
84
91
  <div
@@ -93,7 +100,7 @@
93
100
  {/snippet}
94
101
  {#await dataPromise}
95
102
  <div
96
- class="flex flex-col gap-4 h-full w-full absolute justify-center items-center"
103
+ class="flex flex-col gap-4 h-full w-full justify-center items-center"
97
104
  >
98
105
  <icons.LoaderCircle
99
106
  class="animate-spin opacity-50"
@@ -113,10 +120,17 @@
113
120
  <Table input={data.input} {...props} />
114
121
  {:else if data.type === "chartjs"}
115
122
  <ChartJs input={data.input} {...props} />
123
+ {:else if data.type === "metric"}
124
+ <Metric input={data.input} />
116
125
  {:else}
117
- <div class="text-muted-foreground">
118
- The ({data.type}) chart type is unsupported
119
- </div>
126
+ {@const CustomChart = findCustomChartComponent(data.type)}
127
+ {#if CustomChart}
128
+ <CustomChart input={data.input} {...props} />
129
+ {:else}
130
+ <div class="text-muted-foreground">
131
+ The ({data.type}) chart type is unsupported
132
+ </div>
133
+ {/if}
120
134
  {/if}
121
135
  {/await}
122
136
  </svelte:boundary>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ interface Input {
3
+ value: string | number;
4
+ label?: string;
5
+ prefix?: string;
6
+ suffix?: string;
7
+ }
8
+
9
+ interface Props {
10
+ input: Input;
11
+ }
12
+
13
+ const { input }: Props = $props();
14
+ </script>
15
+
16
+ <div class="flex h-full w-full select-none flex-col items-center justify-center gap-1 p-4">
17
+ <div class="text-5xl font-bold tabular-nums tracking-tight">
18
+ {#if input.prefix}<span class="text-2xl font-medium text-muted-foreground">{input.prefix}</span>{/if}{input.value}{#if input.suffix}<span class="text-2xl font-medium text-muted-foreground">{input.suffix}</span>{/if}
19
+ </div>
20
+ {#if input.label}
21
+ <div class="text-sm text-muted-foreground">{input.label}</div>
22
+ {/if}
23
+ </div>
@@ -0,0 +1,14 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: Record<string, never>;
4
+ events: {
5
+ [evt: string]: CustomEvent<any>;
6
+ };
7
+ slots: {};
8
+ };
9
+ export type MetricProps = typeof __propDef.props;
10
+ export type MetricEvents = typeof __propDef.events;
11
+ export type MetricSlots = typeof __propDef.slots;
12
+ export default class Metric extends SvelteComponentTyped<MetricProps, MetricEvents, MetricSlots> {
13
+ }
14
+ export {};
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { toPng } from "html-to-image";
3
+ import { jsPDF } from "jspdf";
4
+
5
+ interface Props {
6
+ report: any;
7
+ utils: any;
8
+ }
9
+
10
+ const { report, utils }: Props = $props();
11
+ const { Button, Icons } = utils.components;
12
+
13
+ let exporting = $state(false);
14
+
15
+ async function handleExport() {
16
+ const container = document.querySelector<HTMLElement>(".charts-export-container");
17
+ if (!container || !report) return;
18
+ exporting = true;
19
+
20
+ try {
21
+ const backgroundColor = getComputedStyle(document.documentElement).backgroundColor;
22
+
23
+ const imgData = await toPng(container, {
24
+ pixelRatio: 2,
25
+ backgroundColor,
26
+ style: {
27
+ overflow: "visible",
28
+ height: `${container.scrollHeight}px`,
29
+ },
30
+ });
31
+
32
+ const img = new Image();
33
+ img.src = imgData;
34
+ await new Promise((r) => (img.onload = r));
35
+
36
+ const pdf = new jsPDF({ orientation: "landscape", unit: "px" });
37
+ const pageWidth = pdf.internal.pageSize.getWidth();
38
+ const pageHeight = pdf.internal.pageSize.getHeight();
39
+ const ratio = Math.min(pageWidth / img.width, pageHeight / img.height);
40
+
41
+ pdf.addImage(imgData, "PNG", 0, 0, img.width * ratio, img.height * ratio);
42
+ pdf.save(`${report.name ?? "report"}.pdf`);
43
+ } finally {
44
+ exporting = false;
45
+ }
46
+ }
47
+ </script>
48
+
49
+ <Button
50
+ variant="outline"
51
+ class="h-7 px-3 text-xs font-normal"
52
+ Icon={exporting ? Icons.LoaderCircle : Icons.Upload}
53
+ onclick={handleExport}
54
+ disabled={exporting}
55
+ >
56
+ {exporting ? "Exporting..." : "Export"}
57
+ </Button>
@@ -0,0 +1,14 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: Record<string, never>;
4
+ events: {
5
+ [evt: string]: CustomEvent<any>;
6
+ };
7
+ slots: {};
8
+ };
9
+ export type ExportButtonProps = typeof __propDef.props;
10
+ export type ExportButtonEvents = typeof __propDef.events;
11
+ export type ExportButtonSlots = typeof __propDef.slots;
12
+ export default class ExportButton extends SvelteComponentTyped<ExportButtonProps, ExportButtonEvents, ExportButtonSlots> {
13
+ }
14
+ export {};
@@ -1,156 +1,126 @@
1
1
  <script lang="ts">
2
- import type { ExtensionProps } from "@lobb-js/studio";
3
- import type { DateRange } from "bits-ui";
2
+ import { onMount } from "svelte";
3
+ import GridStackComponent from "../../../gridStack.svelte";
4
4
  import Chart from "./chart.svelte";
5
+ import ExportButton from "./exportButton.svelte";
5
6
 
6
- interface Props extends ExtensionProps {
7
- reportId: string;
8
- }
9
-
10
- const props: Props = $props();
11
- const { intlDate } = props.utils;
7
+ const { reportId, utils, ...props }: { reportId: string; utils: any; [key: string]: any } = $props();
12
8
  const {
13
9
  SidebarTrigger,
14
10
  CreateDetailViewButton,
15
11
  Icons,
16
- RangeCalendarButton,
17
- } = props.utils.components;
18
-
19
- let dateRangeValue: DateRange = $state({
20
- start: intlDate
21
- .today(intlDate.getLocalTimeZone())
22
- .subtract({ days: 30 }),
23
- end: intlDate.today(intlDate.getLocalTimeZone()),
24
- });
25
- let context = $derived({
26
- dateRange: {
27
- start: dateRangeValue.start?.toString(),
28
- end: dateRangeValue.end?.toString(),
29
- },
30
- });
12
+ } = utils.components;
31
13
 
32
14
  let report: any = $state(null);
33
- let chartsPromise = $state(getCharts());
15
+ let charts: any[] = $state([]);
16
+ let loading = $state(true);
17
+
18
+ async function loadCharts() {
19
+ loading = true;
20
+ const reportsRes = await utils.lobb.findAll("reports_dashboards", {
21
+ sort: "id",
22
+ filter: { id: reportId },
23
+ });
24
+ report = (await reportsRes.json()).data[0];
25
+
26
+ const chartsRes = await utils.lobb.findAll("reports_charts", {
27
+ filter: { report_id: report.id },
28
+ });
29
+ charts = (await chartsRes.json()).data;
30
+ loading = false;
31
+ }
34
32
 
35
- async function getCharts() {
36
- const reportsResponse = await props.utils.lobb.findAll(
37
- "reports_dashboards",
38
- {
39
- sort: "id",
40
- filter: {
41
- id: props.reportId,
42
- },
43
- },
44
- );
45
- report = (await reportsResponse.json()).data[0];
46
- const chartsResponse = await props.utils.lobb.findAll(
47
- "reports_charts",
48
- {
49
- filter: {
50
- report_id: report.id,
51
- },
52
- },
53
- );
54
- return (await chartsResponse.json()).data;
33
+ async function handleLayoutChange(changes: { id: string; order: number; w: number; h: number }[]) {
34
+ for (const change of changes) {
35
+ await utils.lobb.updateOne("reports_charts", change.id, {
36
+ sort_order: change.order,
37
+ col_span: change.w,
38
+ row_span: change.h,
39
+ });
40
+ }
55
41
  }
56
42
 
57
- async function reloadChart() {
58
- chartsPromise = new Promise(() => {});
59
- chartsPromise = Promise.resolve(await getCharts());
43
+ function gridItemAttrs(chart: any): Record<string, unknown> {
44
+ return { "gs-w": chart.col_span ?? 6, "gs-h": chart.row_span ?? 2, "gs-auto-position": "true" };
60
45
  }
46
+
47
+ onMount(() => loadCharts());
61
48
  </script>
62
49
 
63
- <div class="gridContainer relative h-full w-full flex-col overflow-auto">
64
- {#await chartsPromise}
65
- <div
66
- class="flex flex-col gap-4 h-full w-full absolute justify-center items-center"
67
- >
50
+ <div class="report-layout">
51
+ {#if loading}
52
+ <div class="flex h-full w-full flex-col items-center justify-center gap-4" style="grid-row: 1 / -1">
68
53
  <Icons.LoaderCircle class="animate-spin opacity-50" size="50" />
69
54
  <div class="flex flex-col items-center justify-center">
70
- <div class="text-muted-foreground">
71
- Loading the dashboard...
72
- </div>
73
- <div class="text-muted-foreground text-xs">
74
- Loading all the charts of the selected dashboard
75
- </div>
55
+ <div class="text-muted-foreground">Loading the dashboard...</div>
56
+ <div class="text-xs text-muted-foreground">Loading all the charts of the selected dashboard</div>
76
57
  </div>
77
58
  </div>
78
- {:then charts}
79
- <div
80
- class="flex justify-between gap-2 overflow-auto border-b bg-background p-2"
81
- >
59
+ {:else}
60
+ <div class="flex shrink-0 items-center justify-between gap-2 border-b bg-background p-2">
82
61
  <div class="flex gap-2">
83
- <div class="mt-1">
84
- <SidebarTrigger />
85
- </div>
62
+ <div class="mt-1"><SidebarTrigger /></div>
86
63
  <div class="flex flex-col justify-center">
87
64
  <h2 class="font-medium text-primary">{report.name}</h2>
88
- <div class="text-xs text-muted-foreground">
89
- {report.description}
90
- </div>
65
+ <div class="text-xs text-muted-foreground">{report.description}</div>
91
66
  </div>
92
67
  </div>
93
68
  <div class="flex gap-2 self-end">
94
- <RangeCalendarButton bind:value={dateRangeValue} />
69
+ <ExportButton {report} {utils} />
95
70
  <CreateDetailViewButton
96
71
  collectionName="reports_charts"
97
- values={{
98
- report_id: {
99
- id: props.reportId,
100
- name: report.name,
101
- },
102
- }}
72
+ values={{ report_id: { id: props.reportId, name: report.name } }}
103
73
  variant="default"
104
74
  class="h-7 px-3 text-xs font-normal"
105
75
  Icon={Icons.Plus}
106
- onSuccessfullSave={async () => await reloadChart()}
76
+ onSuccessfullSave={async () => await loadCharts()}
107
77
  >
108
78
  Create chart
109
79
  </CreateDetailViewButton>
110
80
  </div>
111
81
  </div>
112
- <div class="overflow-auto">
113
- <div class="chartsGridContainer w-full flex-wrap gap-4 p-4">
114
- {#if charts.length === 0}
115
- <div
116
- class="flex w-full flex-col items-center justify-center gap-4 p-10 text-muted-foreground"
117
- >
118
- <Icons.CircleSlash2 class="opacity-50" size="50" />
119
- <div class="flex flex-col items-center justify-center">
120
- <div>No charts available</div>
121
- <div class="text-xs">
122
- Create a new chart to fill this report page
123
- </div>
124
- </div>
82
+
83
+ <div class="charts-export-container overflow-auto p-2">
84
+ {#if charts.length === 0}
85
+ <div class="flex w-full flex-col items-center justify-center gap-4 p-10 text-muted-foreground">
86
+ <Icons.CircleSlash2 class="opacity-50" size="50" />
87
+ <div class="flex flex-col items-center justify-center">
88
+ <div>No charts available</div>
89
+ <div class="text-xs">Create a new chart to fill this report page</div>
125
90
  </div>
126
- {:else}
127
- {#each charts as chart}
128
- {#key context}
129
- <Chart
130
- chartRecord={chart}
131
- onChartDeleted={async () => await reloadChart()}
132
- onChartEdited={async () => await reloadChart()}
133
- {context}
134
- {...props}
135
- />
136
- {/key}
137
- {/each}
138
- {/if}
139
- </div>
91
+ </div>
92
+ {:else}
93
+ {#key charts}
94
+ <GridStackComponent onLayoutChange={handleLayoutChange}>
95
+ {#each [...charts].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) as chart (chart.id)}
96
+ <div
97
+ class="grid-stack-item"
98
+ data-gs-id={chart.id}
99
+ {...gridItemAttrs(chart)}
100
+ >
101
+ <div class="grid-stack-item-content">
102
+ <Chart
103
+ chartRecord={chart}
104
+ onChartDeleted={async () => await loadCharts()}
105
+ onChartEdited={async () => await loadCharts()}
106
+ {utils}
107
+ {...props}
108
+ />
109
+ </div>
110
+ </div>
111
+ {/each}
112
+ </GridStackComponent>
113
+ {/key}
114
+ {/if}
140
115
  </div>
141
- {/await}
116
+ {/if}
142
117
  </div>
143
118
 
144
119
  <style>
145
- .gridContainer {
120
+ .report-layout {
146
121
  display: grid;
147
- grid-template-columns: 1fr;
148
122
  grid-template-rows: auto 1fr;
149
- }
150
-
151
- .chartsGridContainer {
152
- display: grid;
153
- grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
154
- grid-gap: 10px;
123
+ width: 100%;
124
+ height: 100%;
155
125
  }
156
126
  </style>
@@ -63,7 +63,7 @@
63
63
 
64
64
  <Sidebar title="Reports" data={sideBarData}>
65
65
  {#snippet belowSearch()}
66
- <div class="p-2 pb-0">
66
+ <div class="p-2">
67
67
  <CreateDetailViewButton
68
68
  collectionName="reports_dashboards"
69
69
  variant="outline"
@@ -105,7 +105,7 @@
105
105
  </div>
106
106
  {/if}
107
107
  {/snippet}
108
- <div class="relative h-full w-full bg-muted">
108
+ <div class="flex h-full w-full flex-col bg-muted">
109
109
  {#if reportId}
110
110
  {#key reportId}
111
111
  <Report {utils} {reportId} {...props} />
@@ -8,30 +8,47 @@ export const charts: CollectionConfig = {
8
8
  },
9
9
  "report_id": {
10
10
  "type": "integer",
11
- "validators": {
12
- "required": true,
13
- },
11
+ "required": true,
14
12
  },
15
13
  "title": {
16
14
  "type": "string",
17
15
  "length": 255,
18
- "validators": {
19
- "required": true,
20
- },
16
+ "required": true,
21
17
  },
22
18
  "description": {
23
19
  "type": "string",
24
20
  "length": 255,
25
21
  },
22
+ "sort_order": {
23
+ "type": "integer",
24
+ },
25
+ "row_span": {
26
+ "type": "integer",
27
+ "default": 2,
28
+ },
29
+ "col_span": {
30
+ "type": "integer",
31
+ "default": 6,
32
+ "enum": [
33
+ { "value": 1, "description": "1/12" },
34
+ { "value": 2, "description": "2/12" },
35
+ { "value": 3, "description": "3/12" },
36
+ { "value": 4, "description": "4/12" },
37
+ { "value": 5, "description": "5/12" },
38
+ { "value": 6, "description": "6/12" },
39
+ { "value": 7, "description": "7/12" },
40
+ { "value": 8, "description": "8/12" },
41
+ { "value": 9, "description": "9/12" },
42
+ { "value": 10, "description": "10/12" },
43
+ { "value": 11, "description": "11/12" },
44
+ { "value": 12, "description": "Full width" },
45
+ ],
46
+ },
26
47
  "chart": {
27
48
  "type": "text",
28
- "validators": {
29
- "required": true,
30
- },
31
- "pre_processors": {
32
- "default":
33
- "async function chart(query: QueryFn, context: Record<string, unknown>) {\n\treturn {\n\t\ttype: 'table',\n\t\tinput: {\n\t\t\tdata: []\n\t\t}\n\t};\n}",
34
- },
49
+ "required": true,
50
+ "default":
51
+ "async function chart(query: QueryFn, context: Record<string, unknown>) {\n\treturn {\n\t\ttype: 'table',\n\t\tinput: {\n\t\t\tdata: []\n\t\t}\n\t};\n}",
35
52
  "ui": {
36
53
  "input": {
37
54
  "type": "code",
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, type Snippet } from "svelte";
3
+ import { GridStack } from "gridstack";
4
+ import "gridstack/dist/gridstack.min.css";
5
+
6
+ interface Props {
7
+ column?: number;
8
+ cellHeight?: string;
9
+ onLayoutChange?: (changes: { id: string; order: number; w: number; h: number }[]) => void;
10
+ children: Snippet;
11
+ }
12
+
13
+ let {
14
+ column = 12,
15
+ cellHeight = "150px",
16
+ onLayoutChange,
17
+ children,
18
+ }: Props = $props();
19
+
20
+ let gridEl: HTMLElement | undefined = $state();
21
+ let grid: GridStack | null = null;
22
+ let initialized = false;
23
+ let resizeObserver: ResizeObserver | null = null;
24
+
25
+ onMount(() => {
26
+ if (!gridEl) return;
27
+
28
+ grid = GridStack.init(
29
+ {
30
+ column,
31
+ cellHeight,
32
+ margin: "0.5rem",
33
+ float: false,
34
+ animate: true,
35
+ columnOpts: {
36
+ layout: "moveScale",
37
+ breakpointForWindow: false,
38
+ breakpoints: [
39
+ { w: 480, c: 1 },
40
+ ],
41
+ },
42
+ },
43
+ gridEl,
44
+ );
45
+
46
+ setTimeout(() => { initialized = true; }, 0);
47
+
48
+ grid.on("dragstop resizestop", () => {
49
+ grid?.compact();
50
+ });
51
+
52
+ grid.on("change", () => {
53
+ if (!onLayoutChange || !initialized) return;
54
+ const currentCols = grid!.getColumn();
55
+ if (currentCols === 1) return;
56
+ const sorted = (grid!.engine.nodes as any[])
57
+ .filter((n) => {
58
+ const id = n.el?.getAttribute("data-gs-id");
59
+ return id != null && id !== "null";
60
+ })
61
+ .sort((a, b) => (a.y !== b.y ? a.y - b.y : a.x - b.x))
62
+ .map((node, index) => ({
63
+ id: node.el!.getAttribute("data-gs-id") as string,
64
+ order: index,
65
+ w: Math.round(node.w * (column / currentCols)),
66
+ h: node.h,
67
+ }));
68
+ if (sorted.length > 0) onLayoutChange(sorted);
69
+ });
70
+
71
+ resizeObserver = new ResizeObserver((entries) => {
72
+ window.dispatchEvent(new Event("resize"));
73
+ const width = entries[0]?.contentRect.width ?? gridEl!.offsetWidth;
74
+ if (width <= 480) {
75
+ grid?.disable();
76
+ } else {
77
+ grid?.enable();
78
+ }
79
+ });
80
+ resizeObserver.observe(gridEl);
81
+ });
82
+
83
+ onDestroy(() => {
84
+ resizeObserver?.disconnect();
85
+ resizeObserver = null;
86
+ grid?.destroy(false);
87
+ grid = null;
88
+ });
89
+ </script>
90
+
91
+ <div class="grid-stack" bind:this={gridEl}>
92
+ {@render children()}
93
+ </div>
94
+
95
+ <style>
96
+ :global(.grid-stack-item-content) {
97
+ inset: 0;
98
+ position: absolute;
99
+ }
100
+
101
+ :global(.grid-stack) {
102
+ background: transparent;
103
+ padding: 0.5rem;
104
+ }
105
+
106
+ :global(.grid-stack-placeholder > .placeholder-content) {
107
+ position: absolute !important;
108
+ width: auto !important;
109
+ border: 2px dashed color-mix(in oklab, var(--muted-foreground) 25%, transparent) !important;
110
+ border-radius: 0.375rem !important;
111
+ background-color: rgba(0, 0, 0, 0.06) !important;
112
+ }
113
+
114
+ :global(.dark .grid-stack-placeholder > .placeholder-content) {
115
+ background-color: rgba(255, 255, 255, 0.06) !important;
116
+ }
117
+ </style>
@@ -2,25 +2,33 @@
2
2
  import type { ExtensionProps } from "@lobb-js/studio";
3
3
  import Table from "./charts/table.svelte";
4
4
  import ChartJs from "./charts/chartJs.svelte";
5
+ import Metric from "./charts/metric.svelte";
5
6
 
6
7
  interface Props extends ExtensionProps {
7
8
  chartRecord: any;
8
9
  onChartDeleted?: () => Promise<void>;
9
10
  onChartEdited?: () => Promise<void>;
10
- context?: Record<string, any>;
11
11
  }
12
12
 
13
13
  const {
14
14
  chartRecord,
15
15
  onChartDeleted,
16
16
  onChartEdited,
17
- context,
18
17
  ...props
19
18
  }: Props = $props();
20
19
 
21
20
  const utils = props.utils;
22
21
  const { UpdateDetailViewButton, Button } = utils.components;
23
22
  const icons = utils.components.Icons;
23
+
24
+ function findCustomChartComponent(type: string): any {
25
+ const extensions = utils.ctx?.extensions ?? {};
26
+ for (const ext of Object.values(extensions) as any[]) {
27
+ const comp = ext.components?.[`reports.chart.${type}`];
28
+ if (comp) return comp;
29
+ }
30
+ return null;
31
+ }
24
32
  let dataPromise = $state(getChartData());
25
33
 
26
34
  async function getChartData() {
@@ -29,7 +37,6 @@
29
37
  chartRecord.id,
30
38
  {
31
39
  action: "run_query",
32
- context: JSON.stringify(context),
33
40
  },
34
41
  );
35
42
 
@@ -57,7 +64,7 @@
57
64
  }
58
65
  </script>
59
66
 
60
- <div class="gridContainer h-96 rounded-md border bg-background">
67
+ <div class="gridContainer h-full rounded-md border bg-background">
61
68
  <div class="flex items-center justify-between border-b p-2 text-sm">
62
69
  <div>{chartRecord.title}</div>
63
70
  <div class="flex">
@@ -78,7 +85,7 @@
78
85
  ></Button>
79
86
  </div>
80
87
  </div>
81
- <div class="flex items-center justify-center overflow-auto">
88
+ <div class="flex min-h-0 items-center justify-center overflow-hidden">
82
89
  <svelte:boundary>
83
90
  {#snippet failed(error, reset)}
84
91
  <div
@@ -93,7 +100,7 @@
93
100
  {/snippet}
94
101
  {#await dataPromise}
95
102
  <div
96
- class="flex flex-col gap-4 h-full w-full absolute justify-center items-center"
103
+ class="flex flex-col gap-4 h-full w-full justify-center items-center"
97
104
  >
98
105
  <icons.LoaderCircle
99
106
  class="animate-spin opacity-50"
@@ -113,10 +120,17 @@
113
120
  <Table input={data.input} {...props} />
114
121
  {:else if data.type === "chartjs"}
115
122
  <ChartJs input={data.input} {...props} />
123
+ {:else if data.type === "metric"}
124
+ <Metric input={data.input} />
116
125
  {:else}
117
- <div class="text-muted-foreground">
118
- The ({data.type}) chart type is unsupported
119
- </div>
126
+ {@const CustomChart = findCustomChartComponent(data.type)}
127
+ {#if CustomChart}
128
+ <CustomChart input={data.input} {...props} />
129
+ {:else}
130
+ <div class="text-muted-foreground">
131
+ The ({data.type}) chart type is unsupported
132
+ </div>
133
+ {/if}
120
134
  {/if}
121
135
  {/await}
122
136
  </svelte:boundary>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ interface Input {
3
+ value: string | number;
4
+ label?: string;
5
+ prefix?: string;
6
+ suffix?: string;
7
+ }
8
+
9
+ interface Props {
10
+ input: Input;
11
+ }
12
+
13
+ const { input }: Props = $props();
14
+ </script>
15
+
16
+ <div class="flex h-full w-full select-none flex-col items-center justify-center gap-1 p-4">
17
+ <div class="text-5xl font-bold tabular-nums tracking-tight">
18
+ {#if input.prefix}<span class="text-2xl font-medium text-muted-foreground">{input.prefix}</span>{/if}{input.value}{#if input.suffix}<span class="text-2xl font-medium text-muted-foreground">{input.suffix}</span>{/if}
19
+ </div>
20
+ {#if input.label}
21
+ <div class="text-sm text-muted-foreground">{input.label}</div>
22
+ {/if}
23
+ </div>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { toPng } from "html-to-image";
3
+ import { jsPDF } from "jspdf";
4
+
5
+ interface Props {
6
+ report: any;
7
+ utils: any;
8
+ }
9
+
10
+ const { report, utils }: Props = $props();
11
+ const { Button, Icons } = utils.components;
12
+
13
+ let exporting = $state(false);
14
+
15
+ async function handleExport() {
16
+ const container = document.querySelector<HTMLElement>(".charts-export-container");
17
+ if (!container || !report) return;
18
+ exporting = true;
19
+
20
+ try {
21
+ const backgroundColor = getComputedStyle(document.documentElement).backgroundColor;
22
+
23
+ const imgData = await toPng(container, {
24
+ pixelRatio: 2,
25
+ backgroundColor,
26
+ style: {
27
+ overflow: "visible",
28
+ height: `${container.scrollHeight}px`,
29
+ },
30
+ });
31
+
32
+ const img = new Image();
33
+ img.src = imgData;
34
+ await new Promise((r) => (img.onload = r));
35
+
36
+ const pdf = new jsPDF({ orientation: "landscape", unit: "px" });
37
+ const pageWidth = pdf.internal.pageSize.getWidth();
38
+ const pageHeight = pdf.internal.pageSize.getHeight();
39
+ const ratio = Math.min(pageWidth / img.width, pageHeight / img.height);
40
+
41
+ pdf.addImage(imgData, "PNG", 0, 0, img.width * ratio, img.height * ratio);
42
+ pdf.save(`${report.name ?? "report"}.pdf`);
43
+ } finally {
44
+ exporting = false;
45
+ }
46
+ }
47
+ </script>
48
+
49
+ <Button
50
+ variant="outline"
51
+ class="h-7 px-3 text-xs font-normal"
52
+ Icon={exporting ? Icons.LoaderCircle : Icons.Upload}
53
+ onclick={handleExport}
54
+ disabled={exporting}
55
+ >
56
+ {exporting ? "Exporting..." : "Export"}
57
+ </Button>
@@ -1,156 +1,126 @@
1
1
  <script lang="ts">
2
- import type { ExtensionProps } from "@lobb-js/studio";
3
- import type { DateRange } from "bits-ui";
2
+ import { onMount } from "svelte";
3
+ import GridStackComponent from "../../../gridStack.svelte";
4
4
  import Chart from "./chart.svelte";
5
+ import ExportButton from "./exportButton.svelte";
5
6
 
6
- interface Props extends ExtensionProps {
7
- reportId: string;
8
- }
9
-
10
- const props: Props = $props();
11
- const { intlDate } = props.utils;
7
+ const { reportId, utils, ...props }: { reportId: string; utils: any; [key: string]: any } = $props();
12
8
  const {
13
9
  SidebarTrigger,
14
10
  CreateDetailViewButton,
15
11
  Icons,
16
- RangeCalendarButton,
17
- } = props.utils.components;
18
-
19
- let dateRangeValue: DateRange = $state({
20
- start: intlDate
21
- .today(intlDate.getLocalTimeZone())
22
- .subtract({ days: 30 }),
23
- end: intlDate.today(intlDate.getLocalTimeZone()),
24
- });
25
- let context = $derived({
26
- dateRange: {
27
- start: dateRangeValue.start?.toString(),
28
- end: dateRangeValue.end?.toString(),
29
- },
30
- });
12
+ } = utils.components;
31
13
 
32
14
  let report: any = $state(null);
33
- let chartsPromise = $state(getCharts());
15
+ let charts: any[] = $state([]);
16
+ let loading = $state(true);
17
+
18
+ async function loadCharts() {
19
+ loading = true;
20
+ const reportsRes = await utils.lobb.findAll("reports_dashboards", {
21
+ sort: "id",
22
+ filter: { id: reportId },
23
+ });
24
+ report = (await reportsRes.json()).data[0];
25
+
26
+ const chartsRes = await utils.lobb.findAll("reports_charts", {
27
+ filter: { report_id: report.id },
28
+ });
29
+ charts = (await chartsRes.json()).data;
30
+ loading = false;
31
+ }
34
32
 
35
- async function getCharts() {
36
- const reportsResponse = await props.utils.lobb.findAll(
37
- "reports_dashboards",
38
- {
39
- sort: "id",
40
- filter: {
41
- id: props.reportId,
42
- },
43
- },
44
- );
45
- report = (await reportsResponse.json()).data[0];
46
- const chartsResponse = await props.utils.lobb.findAll(
47
- "reports_charts",
48
- {
49
- filter: {
50
- report_id: report.id,
51
- },
52
- },
53
- );
54
- return (await chartsResponse.json()).data;
33
+ async function handleLayoutChange(changes: { id: string; order: number; w: number; h: number }[]) {
34
+ for (const change of changes) {
35
+ await utils.lobb.updateOne("reports_charts", change.id, {
36
+ sort_order: change.order,
37
+ col_span: change.w,
38
+ row_span: change.h,
39
+ });
40
+ }
55
41
  }
56
42
 
57
- async function reloadChart() {
58
- chartsPromise = new Promise(() => {});
59
- chartsPromise = Promise.resolve(await getCharts());
43
+ function gridItemAttrs(chart: any): Record<string, unknown> {
44
+ return { "gs-w": chart.col_span ?? 6, "gs-h": chart.row_span ?? 2, "gs-auto-position": "true" };
60
45
  }
46
+
47
+ onMount(() => loadCharts());
61
48
  </script>
62
49
 
63
- <div class="gridContainer relative h-full w-full flex-col overflow-auto">
64
- {#await chartsPromise}
65
- <div
66
- class="flex flex-col gap-4 h-full w-full absolute justify-center items-center"
67
- >
50
+ <div class="report-layout">
51
+ {#if loading}
52
+ <div class="flex h-full w-full flex-col items-center justify-center gap-4" style="grid-row: 1 / -1">
68
53
  <Icons.LoaderCircle class="animate-spin opacity-50" size="50" />
69
54
  <div class="flex flex-col items-center justify-center">
70
- <div class="text-muted-foreground">
71
- Loading the dashboard...
72
- </div>
73
- <div class="text-muted-foreground text-xs">
74
- Loading all the charts of the selected dashboard
75
- </div>
55
+ <div class="text-muted-foreground">Loading the dashboard...</div>
56
+ <div class="text-xs text-muted-foreground">Loading all the charts of the selected dashboard</div>
76
57
  </div>
77
58
  </div>
78
- {:then charts}
79
- <div
80
- class="flex justify-between gap-2 overflow-auto border-b bg-background p-2"
81
- >
59
+ {:else}
60
+ <div class="flex shrink-0 items-center justify-between gap-2 border-b bg-background p-2">
82
61
  <div class="flex gap-2">
83
- <div class="mt-1">
84
- <SidebarTrigger />
85
- </div>
62
+ <div class="mt-1"><SidebarTrigger /></div>
86
63
  <div class="flex flex-col justify-center">
87
64
  <h2 class="font-medium text-primary">{report.name}</h2>
88
- <div class="text-xs text-muted-foreground">
89
- {report.description}
90
- </div>
65
+ <div class="text-xs text-muted-foreground">{report.description}</div>
91
66
  </div>
92
67
  </div>
93
68
  <div class="flex gap-2 self-end">
94
- <RangeCalendarButton bind:value={dateRangeValue} />
69
+ <ExportButton {report} {utils} />
95
70
  <CreateDetailViewButton
96
71
  collectionName="reports_charts"
97
- values={{
98
- report_id: {
99
- id: props.reportId,
100
- name: report.name,
101
- },
102
- }}
72
+ values={{ report_id: { id: props.reportId, name: report.name } }}
103
73
  variant="default"
104
74
  class="h-7 px-3 text-xs font-normal"
105
75
  Icon={Icons.Plus}
106
- onSuccessfullSave={async () => await reloadChart()}
76
+ onSuccessfullSave={async () => await loadCharts()}
107
77
  >
108
78
  Create chart
109
79
  </CreateDetailViewButton>
110
80
  </div>
111
81
  </div>
112
- <div class="overflow-auto">
113
- <div class="chartsGridContainer w-full flex-wrap gap-4 p-4">
114
- {#if charts.length === 0}
115
- <div
116
- class="flex w-full flex-col items-center justify-center gap-4 p-10 text-muted-foreground"
117
- >
118
- <Icons.CircleSlash2 class="opacity-50" size="50" />
119
- <div class="flex flex-col items-center justify-center">
120
- <div>No charts available</div>
121
- <div class="text-xs">
122
- Create a new chart to fill this report page
123
- </div>
124
- </div>
82
+
83
+ <div class="charts-export-container overflow-auto p-2">
84
+ {#if charts.length === 0}
85
+ <div class="flex w-full flex-col items-center justify-center gap-4 p-10 text-muted-foreground">
86
+ <Icons.CircleSlash2 class="opacity-50" size="50" />
87
+ <div class="flex flex-col items-center justify-center">
88
+ <div>No charts available</div>
89
+ <div class="text-xs">Create a new chart to fill this report page</div>
125
90
  </div>
126
- {:else}
127
- {#each charts as chart}
128
- {#key context}
129
- <Chart
130
- chartRecord={chart}
131
- onChartDeleted={async () => await reloadChart()}
132
- onChartEdited={async () => await reloadChart()}
133
- {context}
134
- {...props}
135
- />
136
- {/key}
137
- {/each}
138
- {/if}
139
- </div>
91
+ </div>
92
+ {:else}
93
+ {#key charts}
94
+ <GridStackComponent onLayoutChange={handleLayoutChange}>
95
+ {#each [...charts].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) as chart (chart.id)}
96
+ <div
97
+ class="grid-stack-item"
98
+ data-gs-id={chart.id}
99
+ {...gridItemAttrs(chart)}
100
+ >
101
+ <div class="grid-stack-item-content">
102
+ <Chart
103
+ chartRecord={chart}
104
+ onChartDeleted={async () => await loadCharts()}
105
+ onChartEdited={async () => await loadCharts()}
106
+ {utils}
107
+ {...props}
108
+ />
109
+ </div>
110
+ </div>
111
+ {/each}
112
+ </GridStackComponent>
113
+ {/key}
114
+ {/if}
140
115
  </div>
141
- {/await}
116
+ {/if}
142
117
  </div>
143
118
 
144
119
  <style>
145
- .gridContainer {
120
+ .report-layout {
146
121
  display: grid;
147
- grid-template-columns: 1fr;
148
122
  grid-template-rows: auto 1fr;
149
- }
150
-
151
- .chartsGridContainer {
152
- display: grid;
153
- grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
154
- grid-gap: 10px;
123
+ width: 100%;
124
+ height: 100%;
155
125
  }
156
126
  </style>
@@ -63,7 +63,7 @@
63
63
 
64
64
  <Sidebar title="Reports" data={sideBarData}>
65
65
  {#snippet belowSearch()}
66
- <div class="p-2 pb-0">
66
+ <div class="p-2">
67
67
  <CreateDetailViewButton
68
68
  collectionName="reports_dashboards"
69
69
  variant="outline"
@@ -105,7 +105,7 @@
105
105
  </div>
106
106
  {/if}
107
107
  {/snippet}
108
- <div class="relative h-full w-full bg-muted">
108
+ <div class="flex h-full w-full flex-col bg-muted">
109
109
  {#if reportId}
110
110
  {#key reportId}
111
111
  <Report {utils} {reportId} {...props} />
@@ -33,21 +33,15 @@ export const simpleConfig: Config = {
33
33
  donor_name: {
34
34
  type: "string",
35
35
  length: 255,
36
- validators: {
37
- required: true,
38
- },
36
+ required: true,
39
37
  },
40
38
  amount: {
41
39
  type: "decimal",
42
- validators: {
43
- required: true,
44
- },
40
+ required: true,
45
41
  },
46
42
  created_at: {
47
43
  type: "date",
48
- pre_processors: {
49
- default: "{{ now }}",
50
- },
44
+ default: "{{ now }}",
51
45
  },
52
46
  },
53
47
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-reports",
3
- "version": "0.4.1",
4
- "license": "AGPL-3.0-only",
3
+ "version": "0.6.0",
4
+ "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -34,16 +34,19 @@
34
34
  "package": "svelte-package --input extensions/reports/studio"
35
35
  },
36
36
  "dependencies": {
37
- "@lobb-js/core": "^0.14.0",
37
+ "@lobb-js/core": "^0.23.0",
38
38
  "chart.js": "^4.4.8",
39
+ "gridstack": "^12.6.0",
39
40
  "hono": "^4.7.0",
41
+ "html-to-image": "^1.11.13",
42
+ "jspdf": "^4.2.1",
40
43
  "lodash-es": "^4.17.21",
41
44
  "openapi-types": "^12.1.3"
42
45
  },
43
46
  "devDependencies": {
44
47
  "@faker-js/faker": "^9.6.0",
45
48
  "@playwright/test": "^1.58.2",
46
- "@lobb-js/studio": "^0.8.1",
49
+ "@lobb-js/studio": "^0.19.0",
47
50
  "@lucide/svelte": "^0.563.1",
48
51
  "@sveltejs/adapter-node": "^5.5.4",
49
52
  "@sveltejs/kit": "^2.55.0",