Compare commits

...
Author SHA1 Message Date
ajnart ded5bf03bb fix: use valid widget inset token 2026-08-28 16:50:39 +02:00
ajnart f484306bb3 fix: inset widget edit action 2026-08-28 16:48:31 +02:00
ajnart 649008d327 fix: keep widget edit overlays visible 2026-08-28 16:36:05 +02:00
ajnart e716f847ab fix: rework responsive layout breakpoints 2026-08-28 15:44:25 +02:00
15 changed files with 56 additions and 142 deletions

No files matched your search

+1 -1
View File
@@ -113,7 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user - Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior - Configure global board defaults such as public home boards and status behavior
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
+10 -8
View File
@@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling. The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards. - **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the widest layout and starts at `768px` on new boards. - **Base** is the main layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
their breakpoints. unused breakpoint above Mobile.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails. available breakpoint, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions. becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
Duplicate breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint #### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. `0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
eligible layout with the highest breakpoint, regardless of its role.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout. fallback to select a layout.
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours. including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
@@ -40,6 +40,8 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>; saveSettingsAsync: () => Promise<FormValues | null>;
} }
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const tLayout = useI18n("layout"); const tLayout = useI18n("layout");
@@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
@@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button> </Button>
</Group> </Group>
{form.values.layouts.map((layout, index) => { {displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
.map((candidate) => candidate.breakpoint);
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return ( return (
<Fieldset <Fieldset
@@ -190,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
min={ min={layout.role === "mobile" ? 0 : 1}
layout.role === "base" max={32767}
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
/> />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -1,7 +1,7 @@
.settingsButton { .settingsButton {
position: absolute; position: absolute;
top: -34px; top: calc(var(--mantine-spacing-sm) / 2);
right: 14px; right: calc(var(--mantine-spacing-sm) / 2);
z-index: 201; z-index: 201;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -52,7 +52,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.settingsButton { .settingsButton {
right: 4px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -49,12 +49,14 @@
} }
.widgetIcon { .widgetIcon {
width: var(--mantine-font-size-sm);
height: var(--mantine-font-size-sm);
flex-shrink: 0; flex-shrink: 0;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
} }
.name { .name {
font-size: 11px; font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
@@ -94,7 +96,7 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: var(--chip-background); background-color: var(--chip-background);
font-size: 10px; font-size: var(--mantine-font-size-xxs);
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -2,7 +2,8 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
overflow: clip; overflow-x: clip;
overflow-y: visible;
} }
.viewport[data-board-hydrated="false"] { .viewport[data-board-hydrated="false"] {
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within { .board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: 10; z-index: var(--homarr-z-index-widget-focus);
} }
.board-grid-entry[data-grid-preview="true"] { .board-grid-entry[data-grid-preview="true"] {
-3
View File
@@ -2776,14 +2776,11 @@ export const boardLayoutsNeedRepair = (
) => { ) => {
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
const baseLayout = baseLayouts.at(0);
return ( return (
mobileLayouts.length !== 1 || mobileLayouts.length !== 1 ||
baseLayouts.length !== 1 || baseLayouts.length !== 1 ||
mobileLayouts.at(0)?.breakpoint !== 0 || mobileLayouts.at(0)?.breakpoint !== 0 ||
!baseLayout ||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
); );
}; };
+2 -2
View File
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
} }
} }
const mobileLayout = normalizedLayouts[0]; const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
const baseLayout = normalizedLayouts.at(-1); const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
if (mobileLayout && baseLayout) { if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync( await insertMissingProjectedPositionsAsync(
db, db,
+14 -9
View File
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const orderedLayouts = const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
mobileLayout && baseLayout let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
? [ if (hasProtectedLayouts) {
mobileLayout, orderedLayouts = [
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), mobileLayout,
baseLayout, ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
] ];
: sourceLayouts.toSorted(compareBoardLayouts); }
let previousBreakpoint = -1; let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => { return orderedLayouts.map((layout, index) => {
@@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = <
index === 0 index === 0
? 0 ? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; let role: LayoutRole = layout.role ?? "custom";
if (!hasProtectedLayouts) {
if (index === 0) role = "mobile";
else if (index === orderedLayouts.length - 1) role = "base";
else role = "custom";
}
previousBreakpoint = breakpoint; previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role }; return { ...layout, breakpoint, role };
}); });
+1 -7
View File
@@ -4672,12 +4672,6 @@
"noSectionsEnabled": "Enable at least one section in widget options.", "noSectionsEnabled": "Enable at least one section in widget options.",
"error": { "error": {
"internalServerError": "Failed to fetch speedtest data" "internalServerError": "Failed to fetch speedtest data"
},
"range": {
"twelveHours": "12h",
"oneDay": "24h",
"all": "All",
"lastThirty": "Last 30"
} }
}, },
"uptimeKuma": { "uptimeKuma": {
@@ -5424,7 +5418,7 @@
"title": "Layout", "title": "Layout",
"responsive": { "responsive": {
"title": "Responsive layouts", "title": "Responsive layouts",
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
"action": { "action": {
"add": "Add layout" "add": "Add layout"
} }
-4
View File
@@ -116,10 +116,6 @@ export const responsiveBoardLayoutsSchema = z
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
} }
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
}
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) { if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) { }: WidgetComponentProps<"speedtestTracker">) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
const [advancedRange, setAdvancedRange] = useState("24");
const isAdvanced = displayMode === "advanced";
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
const combined = useMemo( const combined = useMemo(
@@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({
[successfulDashboards], [successfulDashboards],
); );
const rangeHours = isAdvanced ? Number(advancedRange) : 12; const cutoff = Date.now() - 12 * 60 * 60 * 1000;
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const showLatestResult = isAdvanced || options.showLatestResult; const showLatestResult = options.showLatestResult;
const showStats = isAdvanced || options.showStats; const showStats = options.showStats;
const showRecentResults = isAdvanced || options.showRecentResults; const showRecentResults = options.showRecentResults;
const showPingGraph = isAdvanced || options.showPingGraph; const showPingGraph = options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0; const hasChart = showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && ( const latest = showLatestResult && combined.latestResult && (
<LatestResultSection <LatestResultSection result={combined.latestResult} width={width} compactSurface />
result={combined.latestResult}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
const averages = showStats && combined.stats && ( const averages = showStats && combined.stats && (
<AveragesSection <AveragesSection stats={combined.stats} width={width} compactSurface />
stats={combined.stats}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.lastThirty") },
]}
/>
{successfulDashboards.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={sourceColumns} spacing="md">
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
const sourceHasChart = sourceRecent.length > 0;
return (
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
<Stack gap="md">
<Text size="sm" fw={600} truncate>
{integrationName}
</Text>
{sourceHasStats && (
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
{dashboard.latestResult && (
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
)}
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
</SimpleGrid>
)}
{sourceHasChart && (
<Box h={Math.max(260, Math.min(420, height / 2))}>
<RecentResultsSection results={sourceRecent} showPingGraph />
</Box>
)}
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
</Stack>
</Card>
);
})}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
</Box>
);
}
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}> <Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat, icon: IconSpeedboat,
supportsAdvancedFocus: true,
refetchInterval: null, refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(