@stoker-platform/web-app 0.5.52 → 0.5.54

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.54
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: improve charts
8
+ - @stoker-platform/node-client@0.5.36
9
+ - @stoker-platform/utils@0.5.30
10
+ - @stoker-platform/web-client@0.5.32
11
+
12
+ ## 0.5.53
13
+
14
+ ### Patch Changes
15
+
16
+ - feat: add custom list actions
17
+ - @stoker-platform/node-client@0.5.35
18
+ - @stoker-platform/utils@0.5.29
19
+ - @stoker-platform/web-client@0.5.31
20
+
3
21
  ## 0.5.52
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.52",
3
+ "version": "0.5.54",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
@@ -51,9 +51,9 @@
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
52
  "@react-google-maps/api": "^2.20.8",
53
53
  "@sentry/react": "^10.43.0",
54
- "@stoker-platform/node-client": "0.5.34",
55
- "@stoker-platform/utils": "0.5.28",
56
- "@stoker-platform/web-client": "0.5.30",
54
+ "@stoker-platform/node-client": "0.5.36",
55
+ "@stoker-platform/utils": "0.5.30",
56
+ "@stoker-platform/web-client": "0.5.32",
57
57
  "@tanstack/react-table": "^8.21.3",
58
58
  "@types/react": "18.3.13",
59
59
  "@types/react-dom": "18.3.1",
@@ -4,6 +4,7 @@ import {
4
4
  CardsConfig,
5
5
  CollectionField,
6
6
  CollectionSchema,
7
+ CustomListAction,
7
8
  Filter,
8
9
  FormList,
9
10
  ImagesConfig,
@@ -206,6 +207,7 @@ function Collection({
206
207
  const [isOfflineDisabled, setIsOfflineDisabled] = useState<boolean | undefined>(undefined)
207
208
  const [restrictExport, setRestrictExport] = useState<StokerRole[] | undefined>(undefined)
208
209
  const [disableCreate, setDisableCreate] = useState<boolean>(false)
210
+ const [customListActions, setCustomListActions] = useState<CustomListAction[] | undefined>(undefined)
209
211
 
210
212
  const [table, setTable] = useState<Table<StokerRecord> | undefined>(undefined)
211
213
  const [listConfig, setListConfig] = useState<ListConfig | undefined>(undefined)
@@ -840,6 +842,9 @@ function Collection({
840
842
  )
841
843
  setDisableCreate(!!disableCreate)
842
844
  const filters = (await getCachedConfigValue(customization, [...collectionAdminPath, "filters"])) || []
845
+ const customListActions =
846
+ (await getCachedConfigValue(customization, [...collectionAdminPath, "customListActions"])) || []
847
+ setCustomListActions(customListActions)
843
848
 
844
849
  const statusField = await getCachedConfigValue(customization, [...collectionAdminPath, "statusField"])
845
850
  setStatusField(statusField)
@@ -1883,26 +1888,84 @@ function Collection({
1883
1888
  )}
1884
1889
  </ToggleGroup>
1885
1890
  )}
1886
- {!formList &&
1887
- tab === "list" &&
1888
- (!restrictExport || restrictExport.includes(permissions.Role)) && (
1889
- <>
1890
- <Button
1891
- type="button"
1892
- size="sm"
1893
- variant="outline"
1894
- disabled={
1895
- !list.default?.length ||
1896
- isRouteLoading.has(location.pathname)
1897
- }
1898
- className="hidden sm:flex h-7 gap-1"
1899
- onClick={handleExport}
1900
- >
1901
- <File className="h-3.5 w-3.5" />
1902
- <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
1903
- Export
1904
- </span>
1905
- </Button>
1891
+ {!formList && tab === "list" && (
1892
+ <>
1893
+ {customListActions &&
1894
+ customListActions.some(
1895
+ (action) => !action.condition || action.condition(),
1896
+ ) ? (
1897
+ <DropdownMenu>
1898
+ <DropdownMenuTrigger>
1899
+ <Button
1900
+ type="button"
1901
+ size="sm"
1902
+ variant="outline"
1903
+ className="hidden sm:flex h-7 gap-1"
1904
+ >
1905
+ <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
1906
+ Actions
1907
+ </span>
1908
+ <ChevronsUpDown className="h-3.5 w-3.5" />
1909
+ </Button>
1910
+ </DropdownMenuTrigger>
1911
+ <DropdownMenuContent>
1912
+ {(!restrictExport ||
1913
+ restrictExport.includes(permissions.Role)) && (
1914
+ <DropdownMenuItem
1915
+ key="export"
1916
+ onClick={handleExport}
1917
+ disabled={
1918
+ !list.default?.length ||
1919
+ isRouteLoading.has(location.pathname)
1920
+ }
1921
+ >
1922
+ <File className="h-3.5 w-3.5 shrink-0 mr-1" />
1923
+ <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
1924
+ Export
1925
+ </span>
1926
+ </DropdownMenuItem>
1927
+ )}
1928
+ {customListActions
1929
+ .filter(
1930
+ (action) => !action.condition || action.condition(),
1931
+ )
1932
+ .map((action) => (
1933
+ <DropdownMenuItem
1934
+ key={action.title}
1935
+ onClick={action.action}
1936
+ >
1937
+ {action.icon &&
1938
+ createElement(action.icon, {
1939
+ className: "h-3.5 w-3.5 shrink-0 mr-1",
1940
+ })}
1941
+ <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
1942
+ {action.title}
1943
+ </span>
1944
+ </DropdownMenuItem>
1945
+ ))}
1946
+ </DropdownMenuContent>
1947
+ </DropdownMenu>
1948
+ ) : (
1949
+ (!restrictExport || restrictExport.includes(permissions.Role)) && (
1950
+ <Button
1951
+ type="button"
1952
+ size="sm"
1953
+ variant="outline"
1954
+ disabled={
1955
+ !list.default?.length ||
1956
+ isRouteLoading.has(location.pathname)
1957
+ }
1958
+ className="hidden sm:flex h-7 gap-1"
1959
+ onClick={handleExport}
1960
+ >
1961
+ <File className="h-3.5 w-3.5" />
1962
+ <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
1963
+ Export
1964
+ </span>
1965
+ </Button>
1966
+ )
1967
+ )}
1968
+ {(!restrictExport || restrictExport.includes(permissions.Role)) && (
1906
1969
  <CSVLink
1907
1970
  ref={csvLinkRef}
1908
1971
  className="hidden"
@@ -1911,8 +1974,9 @@ function Collection({
1911
1974
  filename={`${collectionTitle}.csv`}
1912
1975
  target="_blank"
1913
1976
  />
1914
- </>
1915
- )}
1977
+ )}
1978
+ </>
1979
+ )}
1916
1980
  {(tab === "cards" || tab === "images") && (
1917
1981
  <DropdownMenu>
1918
1982
  <DropdownMenuTrigger asChild>
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card"
3
3
  import { useCallback, useEffect, useMemo, useState, useRef } from "react"
4
4
  import { getField, getFieldCustomization, tryFunction } from "@stoker-platform/utils"
5
5
  import { getCollectionConfigModule, getLoadingState, getSchema, getTimezone } from "@stoker-platform/web-client"
6
- import { Timestamp, Unsubscribe, WhereFilterOp } from "firebase/firestore"
6
+ import { Timestamp, Unsubscribe } from "firebase/firestore"
7
7
  import { DateTime } from "luxon"
8
8
  import {
9
9
  ChartConfig,
@@ -13,7 +13,6 @@ import {
13
13
  ChartTooltip,
14
14
  ChartTooltipContent,
15
15
  } from "./components/ui/chart"
16
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/ui/select"
17
16
  import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
18
17
  import { getData } from "./utils/getData"
19
18
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
@@ -63,7 +62,7 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
63
62
  }, [])
64
63
 
65
64
  const constraints = useMemo(() => {
66
- const existingConstraints: [string, WhereFilterOp, unknown][] = []
65
+ const existingConstraints = [...(chart.constraints || [])]
67
66
  if (softDelete) {
68
67
  existingConstraints.push(["Archived", "==", false])
69
68
  }
@@ -108,23 +107,59 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
108
107
 
109
108
  type ChartData = { date: string; metric1: number; metric2?: number }[]
110
109
 
111
- const [timeRange, setTimeRange] = useState<string | undefined>(undefined)
112
-
113
- useEffect(() => {
114
- if (chart.type === "area") {
115
- setTimeRange(chart.defaultRange || "30d")
116
- }
117
- }, [])
118
-
119
110
  const chartData: ChartData = useMemo(() => {
120
111
  if (!results) return []
121
112
  const chartData: ChartData = []
122
- if (chart.metricField1) {
113
+ const interval = chart.interval || "day"
114
+ let numberOfIntervals = chart.numberOfIntervals
115
+ if (!numberOfIntervals) {
116
+ if (interval === "day") {
117
+ numberOfIntervals = 90
118
+ } else if (interval === "month") {
119
+ numberOfIntervals = 6
120
+ } else if (interval === "year") {
121
+ numberOfIntervals = 3
122
+ }
123
+ }
124
+ const offset = chart.offset || 0
125
+ if (chart.formula1) {
126
+ results?.forEach((record: StokerRecord) => {
127
+ if (!record[chart.dateField] || !chart.formula1) return
128
+ const date = DateTime.fromJSDate((record[chart.dateField] as Timestamp).toDate(), {
129
+ zone: timezone,
130
+ })
131
+ .startOf(interval)
132
+ .toISO()
133
+ ?.split("T")[0]
134
+ const metric1 = chart.formula1(record)
135
+ let metric2
136
+ if (chart.formula2) {
137
+ metric2 = chart.formula2(record)
138
+ }
139
+ if (date && (metric1 || metric2)) {
140
+ const existingInterval = chartData.find((item) => item.date === date)
141
+ if (existingInterval) {
142
+ existingInterval.metric1 += metric1
143
+ if (existingInterval.metric2 && metric2) {
144
+ existingInterval.metric2 += metric2
145
+ }
146
+ } else {
147
+ chartData.push({ date, metric1, metric2 })
148
+ }
149
+ }
150
+ })
151
+ chartData.sort((a, b) => {
152
+ if (a.date < b.date) return -1
153
+ if (a.date > b.date) return 1
154
+ return 0
155
+ })
156
+ } else if (chart.metricField1) {
123
157
  results?.forEach((record: StokerRecord) => {
124
158
  if (!record[chart.dateField] || !chart.metricField1) return
125
159
  const date = DateTime.fromJSDate((record[chart.dateField] as Timestamp).toDate(), {
126
160
  zone: timezone,
127
161
  })
162
+ .startOf(interval)
128
163
  .toISO()
129
164
  ?.split("T")[0]
130
165
  const metric1 = record[chart.metricField1]
@@ -133,7 +168,15 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
133
168
  metric2 = record[chart.metricField2]
134
169
  }
135
170
  if (date && (metric1 || metric2)) {
136
- chartData.push({ date, metric1, metric2 })
171
+ const existingInterval = chartData.find((item) => item.date === date)
172
+ if (existingInterval) {
173
+ existingInterval.metric1 += metric1
174
+ if (existingInterval.metric2 && metric2) {
175
+ existingInterval.metric2 += metric2
176
+ }
177
+ } else {
178
+ chartData.push({ date, metric1, metric2 })
179
+ }
137
180
  }
138
181
  })
139
182
  chartData.sort((a, b) => {
@@ -149,6 +192,7 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
149
192
  const date = DateTime.fromJSDate((record[chart.dateField] as Timestamp).toDate(), {
150
193
  zone: timezone,
151
194
  })
195
+ .startOf(interval)
152
196
  .toISO()
153
197
  ?.split("T")[0]
154
198
  if (date) {
@@ -172,27 +216,35 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
172
216
  }
173
217
 
174
218
  return chartData?.filter((item) => {
175
- const date = new Date(item.date)
176
- let daysToSubtract = 90
177
- // eslint-disable-next-line security/detect-object-injection
178
- if (timeRange === "30d") {
179
- daysToSubtract = 30
180
- // eslint-disable-next-line security/detect-object-injection
181
- } else if (timeRange === "7d") {
182
- daysToSubtract = 7
183
- }
184
- const startDate = DateTime.now().setZone(timezone).toJSDate()
185
- startDate.setDate(startDate.getDate() - daysToSubtract)
186
- return date >= startDate
219
+ const date = DateTime.fromISO(item.date).setZone(timezone).startOf(interval).toJSDate()
220
+ const startDate = DateTime.now()
221
+ .setZone(timezone)
222
+ .startOf(interval)
223
+ .plus({ [`${interval}s`]: offset })
224
+ .toJSDate()
225
+ const endDate = DateTime.now()
226
+ .setZone(timezone)
227
+ .endOf(interval)
228
+ .plus({ [`${interval}s`]: (numberOfIntervals || 0) + offset })
229
+ .toJSDate()
230
+ return date >= startDate && date < endDate
187
231
  })
188
- }, [results, timeRange])
232
+ }, [results])
189
233
 
190
234
  const metricField1 = chart.metricField1 ? getField(fields, chart.metricField1) : undefined
191
235
  const metricField1Customization = metricField1 ? getFieldCustomization(metricField1, customization) : undefined
192
- const metricField1Title = tryFunction(metricField1Customization?.admin?.label) || metricField1?.name || "Total"
236
+ const metricField1Title =
237
+ tryFunction(chart.label1) ||
238
+ tryFunction(metricField1Customization?.admin?.label) ||
239
+ metricField1?.name ||
240
+ "Total"
193
241
  const metricField2 = chart.metricField2 ? getField(fields, chart.metricField2) : undefined
194
242
  const metricField2Customization = metricField2 ? getFieldCustomization(metricField2, customization) : undefined
195
- const metricField2Title = tryFunction(metricField2Customization?.admin?.label) || metricField2?.name
243
+ const metricField2Title =
244
+ tryFunction(chart.label2) ||
245
+ tryFunction(metricField2Customization?.admin?.label) ||
246
+ metricField2?.name ||
247
+ "Total"
196
248
 
197
249
  const chartConfig = {
198
250
  visitors: {
@@ -211,48 +263,20 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
211
263
  return (
212
264
  <div className="grid gap-3 flex-1 min-w-0 h-full w-full">
213
265
  <Card className="pt-0 w-full h-full">
214
- <div className="grid 2xl:flex h-full">
215
- <CardHeader className="flex flex-col justify-center gap-2 space-y-0 2xl:border-r pb-0 2xl:pb-5 pt-5 w-[200px]">
266
+ <div className="grid h-full">
267
+ <CardHeader className="flex flex-col justify-center gap-2 space-y-0 2xl:border-r pb-0 2xl:pb-5 pt-5 w-[200px] h-[52px]">
216
268
  <div className="grid flex-1 gap-1">
217
269
  <CardTitle>{metricTitle}</CardTitle>
218
270
  </div>
219
- {!isLoading && timeRange && !(isPreloadCacheEnabled && isCacheLoading) && (
220
- <Select
221
- // eslint-disable-next-line security/detect-object-injection
222
- value={timeRange}
223
- onValueChange={(value) =>
224
- // eslint-disable-next-line security/detect-object-injection
225
- setTimeRange(value)
226
- }
227
- >
228
- <SelectTrigger className="w-[160px] rounded-lg" aria-label="Select a value">
229
- <SelectValue placeholder="Last 3 months" />
230
- </SelectTrigger>
231
- <SelectContent className="rounded-xl">
232
- <SelectItem value="90d" className="rounded-lg">
233
- Last 3 months
234
- </SelectItem>
235
- <SelectItem value="30d" className="rounded-lg">
236
- Last 30 days
237
- </SelectItem>
238
- <SelectItem value="7d" className="rounded-lg">
239
- Last 7 days
240
- </SelectItem>
241
- </SelectContent>
242
- </Select>
243
- )}
244
271
  </CardHeader>
245
272
  <CardContent className="flex-1 px-2 sm:px-6 pb-0">
246
273
  {connectionStatus === "online" || isPreloadCacheEnabled ? (
247
274
  isLoading || (isPreloadCacheEnabled && isCacheLoading) ? (
248
- <div className="flex items-center justify-center h-[294px] md:h-[269px] 2xl:h-[325px]">
275
+ <div className="flex items-center justify-center h-[271px]">
249
276
  <LoadingSpinner size={7} className="relative bottom-6" />
250
277
  </div>
251
278
  ) : (
252
- <ChartContainer
253
- config={chartConfig}
254
- className="aspect-auto w-full h-[250px] md:h-[225px] 2xl:h-[325px]"
255
- >
279
+ <ChartContainer config={chartConfig} className="aspect-auto w-full h-[271px]">
256
280
  <AreaChart data={chartData}>
257
281
  <defs>
258
282
  <linearGradient id="fill1" x1="0" y1="0" x2="0" y2="1">
@@ -273,22 +297,35 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
273
297
  minTickGap={32}
274
298
  tickFormatter={(value) => {
275
299
  const date = new Date(value)
276
- return date.toLocaleDateString("en-US", {
277
- month: "short",
278
- day: "numeric",
279
- })
300
+ if (chart.interval === "month" || chart.interval === "year") {
301
+ return date.toLocaleDateString("en-US", {
302
+ month: "short",
303
+ })
304
+ } else {
305
+ return date.toLocaleDateString("en-US", {
306
+ month: "short",
307
+ day: "numeric",
308
+ })
309
+ }
280
310
  }}
281
311
  />
282
- <YAxis hide padding={{ top: 16 }} />
312
+ <YAxis hide={!tryFunction(chart.yAxis?.show)} padding={{ top: 16 }} />
283
313
  <ChartTooltip
284
314
  cursor={false}
285
315
  content={
286
316
  <ChartTooltipContent
287
317
  labelFormatter={(value) => {
288
- return new Date(value).toLocaleDateString("en-US", {
289
- month: "short",
290
- day: "numeric",
291
- })
318
+ const date = new Date(value)
319
+ if (chart.interval === "month" || chart.interval === "year") {
320
+ return date.toLocaleDateString("en-US", {
321
+ month: "short",
322
+ })
323
+ } else {
324
+ return date.toLocaleDateString("en-US", {
325
+ month: "short",
326
+ day: "numeric",
327
+ })
328
+ }
292
329
  }}
293
330
  indicator="dot"
294
331
  />
@@ -301,7 +338,7 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
301
338
  stroke="var(--chart-dark)"
302
339
  stackId="a"
303
340
  />
304
- {metricField2 && (
341
+ {(metricField2 || chart.formula2) && (
305
342
  <Area
306
343
  dataKey="metric2"
307
344
  type="natural"
@@ -310,7 +347,7 @@ export const DashboardChart = ({ chart, title, collection }: DashboardChartProps
310
347
  stackId="a"
311
348
  />
312
349
  )}
313
- {metricField1 && (
350
+ {(metricField1 || chart.formula1) && (
314
351
  <ChartLegend className="pb-3" content={<ChartLegendContent />} />
315
352
  )}
316
353
  </AreaChart>
@@ -9,7 +9,6 @@ import { getData } from "./utils/getData"
9
9
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
10
10
  import { LoadingSpinner } from "./components/ui/loading-spinner"
11
11
  import { useConnection } from "./providers/ConnectionProvider"
12
- import { WhereFilterOp } from "firebase/firestore"
13
12
 
14
13
  interface DashboardMetricProps {
15
14
  metric: Metric
@@ -53,7 +52,7 @@ export const DashboardMetric = ({ metric, title, collection }: DashboardMetricPr
53
52
  }, [])
54
53
 
55
54
  const constraints = useMemo(() => {
56
- const existingConstraints: [string, WhereFilterOp, unknown][] = []
55
+ const existingConstraints = [...(metric.constraints || [])]
57
56
  if (softDelete) {
58
57
  existingConstraints.push(["Archived", "==", false])
59
58
  }
@@ -259,7 +259,7 @@ export const DashboardReminder = ({ reminder, title, collection }: DashboardRemi
259
259
  colSpan={reminder.columns?.length || 1}
260
260
  className="text-center h-10 text-primary/50"
261
261
  >
262
- <span>{collectionTitle} are not available in offline mode.</span>
262
+ <span>{collectionTitle} not available in offline mode.</span>
263
263
  </TableCell>
264
264
  </TableRow>
265
265
  </TableBody>
package/src/List.tsx CHANGED
@@ -1219,6 +1219,7 @@ export function List({
1219
1219
  const date = DateTime.fromJSDate((record[metric.dateField] as Timestamp).toDate(), {
1220
1220
  zone: timezone,
1221
1221
  })
1222
+ .startOf("day")
1222
1223
  .toISO()
1223
1224
  ?.split("T")[0]
1224
1225
  const metric1 = record[metric.metricField1]
@@ -1227,7 +1228,15 @@ export function List({
1227
1228
  metric2 = record[metric.metricField2]
1228
1229
  }
1229
1230
  if (date && (metric1 || metric2)) {
1230
- chartData.push({ date, metric1, metric2 })
1231
+ const existingInterval = chartData.find((item) => item.date === date)
1232
+ if (existingInterval) {
1233
+ existingInterval.metric1 += metric1
1234
+ if (existingInterval.metric2 && metric2) {
1235
+ existingInterval.metric2 += metric2
1236
+ }
1237
+ } else {
1238
+ chartData.push({ date, metric1, metric2 })
1239
+ }
1231
1240
  }
1232
1241
  })
1233
1242
  chartData.sort((a, b) => {
@@ -1246,6 +1255,7 @@ export function List({
1246
1255
  const date = DateTime.fromJSDate((record[metric.dateField] as Timestamp).toDate(), {
1247
1256
  zone: timezone,
1248
1257
  })
1258
+ .startOf("day")
1249
1259
  .toISO()
1250
1260
  ?.split("T")[0]
1251
1261
 
@@ -1413,8 +1423,9 @@ export function List({
1413
1423
  daysToSubtract = 7
1414
1424
  }
1415
1425
  const startDate = DateTime.now().setZone(timezone).toJSDate()
1426
+ const endDate = DateTime.now().setZone(timezone).toJSDate()
1416
1427
  startDate.setDate(startDate.getDate() - daysToSubtract)
1417
- return date >= startDate
1428
+ return date >= startDate && date <= endDate
1418
1429
  })
1419
1430
 
1420
1431
  return (
@@ -203,7 +203,7 @@ const ChartTooltipContent = React.forwardRef<
203
203
  )}
204
204
  <div
205
205
  className={cn(
206
- "flex flex-1 justify-between leading-none",
206
+ "flex flex-1 justify-between leading-none gap-x-2",
207
207
  nestLabel ? "items-end" : "items-center",
208
208
  )}
209
209
  >