Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1735aeb139 | ||
|
|
92a7a83e92 | ||
|
|
0b8e2e0927 | ||
|
|
1fbe844598 | ||
|
|
365ef7f02b | ||
|
|
2b1a03849d | ||
|
|
4c243bc374 | ||
|
|
ecd02f73c0 | ||
|
|
cd23b36f3c | ||
|
|
f7f29eade9 | ||
|
|
32a51c1e60 | ||
|
|
da32acb4f0 | ||
|
|
1da13d9bb3 | ||
|
|
cb0fec088f | ||
|
|
a2fe8dd461 | ||
|
|
b6dfcd69ba | ||
|
|
a38b0ef71d | ||
|
|
2cf3027eeb | ||
|
|
fa18f6fc71 | ||
|
|
331925c197 | ||
|
|
0e51f745d3 | ||
|
|
d6f720d0e6 | ||
|
|
5324bf12d4 | ||
|
|
2922a02ecf | ||
|
|
87cdcf28e0 | ||
|
|
441b181e79 | ||
|
|
eba639b59c | ||
|
|
da36283ad1 | ||
|
|
c050368782 | ||
|
|
82b50f0fd7 | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d | ||
|
|
aca3b5d861 | ||
|
|
a7a2f0223a | ||
|
|
7773fdc10b | ||
|
|
7019a1d0a4 | ||
|
|
2d535caeac | ||
|
|
2013fbe80d | ||
|
|
219ef58926 | ||
|
|
97195bc463 | ||
|
|
73a742154e | ||
|
|
993a267e0c | ||
|
|
f1c49d1fbf | ||
|
|
95c66c61eb | ||
|
|
ec2f8c6c2c | ||
|
|
05ba57c2fa | ||
|
|
ddbf05524b | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
8d5ffb2dd9 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference
|
||||
alwaysApply: true
|
||||
name: codebase-context
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
|
||||
---
|
||||
|
||||
# Homarr Codebase Context
|
||||
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
```text
|
||||
homarr/
|
||||
├── apps/
|
||||
│ ├── nextjs/ # Main Next.js application (port 3000)
|
||||
@@ -66,8 +66,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
@@ -79,11 +77,11 @@ homarr/
|
||||
|
||||
## Three Runtime Services
|
||||
|
||||
| Service | Port | Role |
|
||||
|---------|------|------|
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
| Service | Port | Role |
|
||||
| --------- | ---- | -------------------------------------------- |
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
|
||||
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
|
||||
|
||||
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
|
||||
### Multi-Database Support
|
||||
|
||||
Drizzle schemas exist in three parallel implementations:
|
||||
|
||||
- `packages/db/schema/sqlite.ts`
|
||||
- `packages/db/schema/mysql.ts`
|
||||
- `packages/db/schema/postgresql.ts`
|
||||
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
||||
### Widget System
|
||||
|
||||
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
||||
|
||||
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
||||
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
||||
- Loads dynamically via `next/dynamic`
|
||||
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
|
||||
### Cron Job System
|
||||
|
||||
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
|
||||
|
||||
- Uses `createCronJob` with a cron expression
|
||||
- Publishes results to Redis channels
|
||||
- Widget subscriptions consume those channels via tRPC subscriptions
|
||||
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
|
||||
## Environment Variables
|
||||
|
||||
Key env vars (from `.env.example`):
|
||||
|
||||
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
|
||||
- `DB_URL`: Database connection string
|
||||
- `AUTH_SECRET`: NextAuth secret
|
||||
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
|
||||
- Drag-and-drop via `@dnd-kit/*`
|
||||
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
|
||||
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
|
||||
|
||||
## Documentation (`apps/docs`)
|
||||
|
||||
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
|
||||
|
||||
- Run with `pnpm dev:docs` from root
|
||||
- Build with `pnpm turbo build --filter=@homarr/docs`
|
||||
- Uses Tailwind CSS 3 for custom pages (not Mantine)
|
||||
- Imports `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
|
||||
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
- Strict link checking: broken links/anchors cause build failure
|
||||
+89
-61
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: datatable-migration
|
||||
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
|
||||
---
|
||||
|
||||
# Migrating Tables from mantine-react-table to mantine-datatable
|
||||
|
||||
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
|
||||
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
|
||||
Current `mantine-react-table` consumers that could be migrated:
|
||||
|
||||
**Widgets (in `packages/widgets/`):**
|
||||
|
||||
- `docker/component.tsx` — Docker container table
|
||||
- `media-server/component.tsx` — Media server sessions
|
||||
|
||||
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
|
||||
|
||||
- `tools/docker/docker-table.tsx`
|
||||
- `tools/tasks/_components/tasks-table.tsx`
|
||||
- `tools/api/components/api-keys.tsx`
|
||||
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
|
||||
- `tools/kubernetes/` — 8 Kubernetes resource tables
|
||||
|
||||
**Modals:**
|
||||
|
||||
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
|
||||
|
||||
**Shared hook to remove once all migrated:**
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
---
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
## 1. Core Migration: mantine-react-table → mantine-datatable
|
||||
|
||||
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- `accessorKey` → `accessor`
|
||||
- `header` → `title`
|
||||
- `size` → `width`
|
||||
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
|
||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Making Tables Transparent (Widget Context)
|
||||
|
||||
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
|
||||
@@ -178,8 +184,6 @@ Create a CSS file alongside the component:
|
||||
|
||||
Import it: `import "./styles.css";` and apply via `className="my-table"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive Column Visibility
|
||||
|
||||
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
|
||||
@@ -195,11 +199,38 @@ interface SizeConfig {
|
||||
}
|
||||
|
||||
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
||||
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
|
||||
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
|
||||
{
|
||||
maxWidth: 300,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 12,
|
||||
cellPadding: 2,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: false,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
maxWidth: 500,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = {
|
||||
fontSize: "sm",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: true,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: true,
|
||||
};
|
||||
|
||||
function getSizeConfig(width: number): SizeConfig {
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
@@ -230,8 +261,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Column Persistence (Drag/Resize → Server)
|
||||
|
||||
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
|
||||
@@ -263,15 +292,6 @@ Even hidden options need translation keys or the translation test fails:
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
@@ -300,10 +320,15 @@ useEffect(() => {
|
||||
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
|
||||
if (Object.keys(savedWidths).length > 0) {
|
||||
setMultipleColumnWidths(
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({
|
||||
accessor,
|
||||
width: w,
|
||||
})),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(() => { hydrated.current = true; });
|
||||
requestAnimationFrame(() => {
|
||||
hydrated.current = true;
|
||||
});
|
||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
||||
```
|
||||
|
||||
@@ -330,9 +355,9 @@ useEffect(() => {
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const w = entry[key as keyof typeof entry];
|
||||
if (typeof w === "number") widthMap[key] = w;
|
||||
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
|
||||
const v = entry[key as keyof typeof entry];
|
||||
if (typeof v === "number") widthMap[key] = v;
|
||||
else if (typeof v === "string" && v.endsWith("px")) widthMap[key] = parseInt(v, 10);
|
||||
}
|
||||
persistOption({ columnWidths: JSON.stringify(widthMap) });
|
||||
}
|
||||
@@ -341,8 +366,6 @@ useEffect(() => {
|
||||
|
||||
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
|
||||
|
||||
---
|
||||
|
||||
## 5. Context Menu (Right-Click)
|
||||
|
||||
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
|
||||
@@ -379,10 +402,24 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
<Portal>
|
||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
||||
<Box
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
/* action */ onClose();
|
||||
}}
|
||||
>
|
||||
Action
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Portal>
|
||||
@@ -392,8 +429,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Row Expansion
|
||||
|
||||
```typescript
|
||||
@@ -413,8 +448,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Common Pitfalls & Fixes
|
||||
|
||||
### Status indicator dots
|
||||
@@ -475,8 +508,11 @@ Always wrap in try/catch:
|
||||
```typescript
|
||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value) as T; }
|
||||
catch { return fallback; }
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -494,20 +530,19 @@ If the widget has configurable default sort, sync it:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
||||
setSortStatus({
|
||||
columnAccessor: options.defaultSort,
|
||||
direction: defaultSortDirection,
|
||||
});
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
- All `ActionIcon` components need `aria-label`
|
||||
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
|
||||
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
- Every mutation needs an `onError` callback with `showErrorNotification`
|
||||
@@ -518,12 +553,14 @@ useEffect(() => {
|
||||
```typescript
|
||||
const mutationOptions = {
|
||||
onSettled: () => void utils.myQuery.invalidate(),
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: t("errors.actionFailed"),
|
||||
message: t("errors.actionFailedMessage"),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
|
||||
@@ -534,8 +571,6 @@ const mutationOptions = {
|
||||
- Errors: `t("errors.{errorKey}")`
|
||||
- Hidden options still need `.label` translation keys
|
||||
|
||||
---
|
||||
|
||||
## 11. Edit Mode
|
||||
|
||||
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
|
||||
@@ -550,8 +585,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DataTable Props Reference (Commonly Used)
|
||||
|
||||
```typescript
|
||||
@@ -590,8 +623,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Layout Stability
|
||||
|
||||
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
|
||||
@@ -600,8 +631,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
|
||||
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
|
||||
|
||||
---
|
||||
|
||||
## 14. Checklist for Each Migration
|
||||
|
||||
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
|
||||
@@ -610,13 +639,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
7. Add translation keys (including hidden option labels)
|
||||
8. Add context menu if row actions exist
|
||||
9. Add row expansion if detail view exists
|
||||
10. Add error handling on all mutations
|
||||
11. Add accessibility attributes
|
||||
12. Test at multiple widget widths
|
||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
14. Run `pnpm turbo typecheck`
|
||||
15. Run translation spec — catches missing keys
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: documentation-sync
|
||||
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
|
||||
---
|
||||
|
||||
# Documentation Sync
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
description: How to expose tRPC procedures as MCP tools for AI clients
|
||||
globs: packages/api/src/router/**/*.ts
|
||||
alwaysApply: false
|
||||
name: mcp-integration
|
||||
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
|
||||
---
|
||||
|
||||
# MCP Integration Guide
|
||||
# MCP Integration
|
||||
|
||||
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
||||
|
||||
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
|
||||
|
||||
## Adding MCP to a procedure
|
||||
|
||||
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
||||
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
|
||||
// Read-only tool (query)
|
||||
myProcedure: protectedProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
||||
.input(z.object({ limit: z.number().default(50) }))
|
||||
.input(z.object({ limit: z.number().default(10) }))
|
||||
.query(async ({ ctx, input }) => { ... })
|
||||
|
||||
// Write tool (mutation)
|
||||
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
|
||||
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
||||
|
||||
**Must include:**
|
||||
|
||||
- What the tool returns or does
|
||||
- Which integrations/services it works with (if applicable)
|
||||
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
||||
- Permission requirements (if non-obvious)
|
||||
|
||||
**Good:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events"
|
||||
```
|
||||
|
||||
**For tools that return permission fields**, explain what they mean:
|
||||
```
|
||||
|
||||
```text
|
||||
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
||||
```
|
||||
|
||||
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
|
||||
## What to expose vs. what to skip
|
||||
|
||||
**Expose:**
|
||||
|
||||
- Data queries (list, search, get by ID)
|
||||
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
||||
- Status checks (health, stats, summaries)
|
||||
|
||||
**Skip:**
|
||||
|
||||
- Subscriptions (WebSocket-only, not supported by MCP)
|
||||
- Internal procedures (session management, onboarding steps)
|
||||
- File upload/download procedures
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
description: Keep documentation in sync with code changes
|
||||
globs:
|
||||
- packages/definitions/src/**
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/**
|
||||
- apps/nextjs/src/**
|
||||
- apps/docs/**
|
||||
---
|
||||
|
||||
# Documentation Sync Rule
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
|---|---|
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -33,6 +33,11 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.77.0
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
|
||||
@@ -68,8 +68,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
run: |
|
||||
|
||||
@@ -74,9 +74,6 @@ apps/nextjs/public/images/background.png
|
||||
.opencode
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
.agents/skills/*
|
||||
!.agents/skills/homarr-custom-widget/
|
||||
!.agents/skills/homarr-custom-widget/**
|
||||
skills-lock.json
|
||||
|
||||
# docusaurus
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mantine": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mantine/mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- Lint: oxlint (not ESLint)
|
||||
- Format: oxfmt (not Prettier)
|
||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
|
||||
|
||||
- Icons: @tabler/icons-react
|
||||
- Docs app can import from `@homarr/definitions` for shared types
|
||||
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
|
||||
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
|
||||
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
+2
-4
@@ -10,7 +10,7 @@ Homarr is used by self-hosters and administrators who assemble dashboards for se
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service. Boards must load immediately, stay visually stable, keep widgets legible across screen sizes, and make editing feel direct and forgiving.
|
||||
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
@@ -27,9 +27,7 @@ Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, h
|
||||
3. Make simple tasks short without limiting advanced use cases.
|
||||
4. Isolate failures so one service or widget cannot disrupt the dashboard.
|
||||
5. Use the existing Mantine design language consistently across management and board surfaces.
|
||||
6. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback.
|
||||
7. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations.
|
||||
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, and actionable error messages.
|
||||
@@ -12,20 +12,6 @@ Homarr has the following keyboard shortcuts:
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
|
||||
## Board editing
|
||||
|
||||
When a tile is focused in edit mode:
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| `Enter` or `Space` | Starts or stops keyboard editing |
|
||||
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
|
||||
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
|
||||
| `Esc` | Stops keyboard editing without undoing its changes |
|
||||
|
||||
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
|
||||
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
@@ -137,8 +137,7 @@ It is important that you first migrate correctly using [the list of breaking cha
|
||||
## Creating your first board
|
||||
|
||||
In Homarr, you organize your apps and widgets on boards.
|
||||
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
|
||||
Drag, resize, and section controls are available in edit mode.
|
||||
Boards can be organized using the interactive drag and drop.
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
@@ -189,7 +188,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
|
||||
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
|
||||
|
||||
- **Add item**: Click this button to add new elements to your page.
|
||||
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
|
||||
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
|
||||
|
||||
To continue, click the option "New Item":
|
||||
|
||||
@@ -214,29 +213,21 @@ In the modal you can edit the app that you want to display:
|
||||

|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Use the resize handle at the bottom right to resize the tile.
|
||||
Press and hold the handle, move the pointer, and release it when the tile has the size you want.
|
||||
Using the small bidirectional arrow at the bottom right you can resize the element.
|
||||
Start pressing with your mouse button down, move your mouse to resize and release your finger as soon your you're happy with the size.
|
||||
|
||||

|
||||
|
||||
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
|
||||
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
|
||||
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
|
||||
To move your item to a different position on the board, simply hold the mouse button anywhere on the item and move with the mouse to the desired position.
|
||||
As soon as you release your finger, the item will snap in place in the grid.
|
||||
|
||||

|
||||
|
||||
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
|
||||
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
|
||||
Homarr scales the complete board uniformly, like zooming a page.
|
||||
The complete canvas always fits the available width without horizontal board scrolling.
|
||||
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
Viewing a board initially uses the lightweight, non-interactive layout.
|
||||
For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive.
|
||||
If you enter edit mode before that finishes, the existing board stays visible until the editor is ready.
|
||||
You cannot move items freely on the vertical axis.
|
||||
We always align all items to the top of the page to simply organization.
|
||||
To move items to the bottom, you must add more items or resize items.
|
||||
|
||||
:::
|
||||
|
||||
@@ -247,10 +238,8 @@ This helps you to explore the capabilities of Homarr before you build your board
|
||||
|
||||
:::
|
||||
|
||||
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
|
||||
Press Escape to stop.
|
||||
For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu.
|
||||
The dialog can also move the tile to another section or fixed sidebar:
|
||||
Users on touch or mobile devices may not be able to drag and drop or move items.
|
||||
They can use the "Move / resize item" option in the edit menu of items instead:
|
||||
|
||||

|
||||
|
||||
@@ -322,36 +311,43 @@ As with every change, don't forget to save your changes using the exit edit mode
|
||||
|
||||

|
||||
|
||||
## Organizing items with Containers
|
||||
## Grouping your items
|
||||
|
||||
Containers are resizable areas for apps, widgets, and nested Containers.
|
||||
The same Container works on the main canvas, inside a fixed rail, or inside another Container.
|
||||
Each Container can configure:
|
||||
As the last step, let's organize your freshly created items using two different options.
|
||||
You can choose either or both of them on your boards.
|
||||
|
||||
- Its label and whether that label is visible.
|
||||
- Allow or disable collapsing.
|
||||
- Show or hide **Open all in new tabs**.
|
||||
- Change its border color and custom CSS classes.
|
||||
### Using dynamic sections
|
||||
|
||||
Open the add-content menu and choose **New Container**.
|
||||
In edit mode, move and resize it like any other item, then drag items into or out of it.
|
||||
Nested content stays within the Container's bounds.
|
||||
While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release.
|
||||
Dynamic sections are a simple, resizable visual group to group items.
|
||||
|
||||
### Collapsing and opening apps
|
||||

|
||||
|
||||
Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
|
||||
The full-width header becomes the compact expansion bar, so the half-row state has no empty pill-shaped spacer.
|
||||
Expanding it restores its bounds and makes room for its contents.
|
||||
If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
|
||||
After adding a dynamic section, you can simply drag and drop items into the section:
|
||||
|
||||
### Fixed rails
|
||||

|
||||
|
||||
Open the board's **Layout** settings to enable the left or right rail for each responsive layout.
|
||||
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
|
||||
The rail reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
|
||||
Rail content stays visible while the page scrolls and remains editable.
|
||||
Each responsive layout keeps its own rail widths, item positions, and item sizes.
|
||||
### Using categories
|
||||
|
||||
Categories are more advanced and complicated than dynamic sections.
|
||||
To create a category, open the add item modal and choose "new category":
|
||||
|
||||

|
||||
|
||||
Every category must have a name. After you click "Add category", you will get a new item on your board:
|
||||
|
||||

|
||||
|
||||
You can drag and drop items in to this category in the edit mode.
|
||||
Categories will have a big space before and after them in the edit mode to
|
||||
enable you to drag and drop items outside the category.
|
||||
You can also collapse or open categories in edit mode to facilitate your editing process.
|
||||
|
||||
:::note
|
||||
|
||||
A current limitation of categories is, that they will always have a width of 100%.
|
||||
You cannot reduce their horizontal width.
|
||||
|
||||
:::
|
||||
|
||||
## Guided tours
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Glossary
|
||||
|
||||
This glossary explains Homarr's main components and the terms used for them.
|
||||
In the glossary, you'll learn the different components and their names of Homarr.
|
||||
|
||||
| Term | Definition |
|
||||
| ----------- | --------------------------------------------------------------------------------------- |
|
||||
| Apps | A piece of software that you or a third party hosts (for example, Google) |
|
||||
| Integration | A connection to a service outside of Homarr |
|
||||
| Container | A resizable area that can hold apps, widgets, and nested Containers |
|
||||
| Widget | A tile that uses your integrations to display data or enables you to interact with them |
|
||||
| Edit mode | A temporary state where you can drag, resize, edit, or customize your board |
|
||||
| Board | A customizable page where apps, widgets, and Containers are arranged |
|
||||
| Tasks | Workloads that are performed in the background, even if no user is present |
|
||||
| Term | Definition |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Apps | An piece of software that you or a third party hosts (eg. Google) |
|
||||
| Integration | A connection to a service outside of Homarr |
|
||||
| Dynamic section | Resizeable area where items on your board can be placed and grouped |
|
||||
| Widget | A tile that uses your integrations to display data or enables you to interact with with them |
|
||||
| Edit mode | Temporary state that you can enter where you can drag and drop, edit or customize your board |
|
||||
| Board | Customizable page which can be edited using the edit mode. This is where apps, sections and widgets are placed |
|
||||
| Tasks | Workloads that are performed in the background - even if there is no user present |
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -437,7 +437,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -19,6 +19,8 @@ import Alert from "@theme/Admonition";
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
||||
|
||||
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -84,13 +84,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
### Saving dashboard layouts
|
||||
|
||||
The dashboard editor saves item positions and sections as one board update.
|
||||
Canvas height follows its content automatically and does not require a separate API setting.
|
||||
Responsive layouts store optional left and right gutter widths from zero to three columns.
|
||||
Gutter columns are reserved from the layout's total column count, and changing them reflows affected items.
|
||||
|
||||
## Get Custom Widget authoring resources
|
||||
|
||||
Custom JSX v2 provides four HTTP discovery endpoints:
|
||||
|
||||
@@ -36,10 +36,8 @@ Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
|
||||
The column count defines how many fixed-width cells are available in each row.
|
||||
One cell contains a `200 × 200` logical-pixel tile.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling.
|
||||
The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge.
|
||||
Homarr has a complex drag-and-drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
@@ -90,78 +88,26 @@ Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
### Layout
|
||||
|
||||
In this section you can configure the different responsive layouts.
|
||||
Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly.
|
||||
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
|
||||
|
||||

|
||||
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
|
||||
The `24` logical-pixel gap between tiles is separate from that size.
|
||||
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
|
||||
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
|
||||
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
|
||||
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 position and size of every item are independent in each responsive layout.
|
||||
Moving or resizing an item in one layout does not change any other layout.
|
||||
When adding a new layout, its initial positions and sizes are generated from the column count.
|
||||
New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns.
|
||||
Both are placed in the first available position.
|
||||
|
||||
The canvas is at least one window tall, even when it is empty.
|
||||
Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward.
|
||||
Nested Containers keep their own resizable bounds.
|
||||
On each element the position and size of items can be changed seperately.
|
||||
When adding a new layout the position and sizes will automatically be generated depending on the column count.
|
||||
When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position.
|
||||
|
||||
#### Name
|
||||
|
||||
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
|
||||
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
|
||||
|
||||
#### Column count
|
||||
|
||||
Total number of columns for this layout.
|
||||
When changed, Homarr moves items into the next available positions without overlap.
|
||||
|
||||
#### Fixed rails
|
||||
|
||||
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
|
||||
Set a rail width to zero to disable it for that layout.
|
||||
Choose a width from one to three columns with the three-step slider.
|
||||
The preview shows how much space remains for the main canvas.
|
||||
|
||||
Rail columns are reserved from the layout's total column count.
|
||||
For example, a 12-column layout with a two-column left rail leaves ten columns for the canvas.
|
||||
Enabling or widening a rail reflows canvas items into the remaining columns.
|
||||
Disabling a rail moves its contents back into the next available canvas positions.
|
||||
|
||||
Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently.
|
||||
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
|
||||
Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items.
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached, the layout will change.
|
||||
|
||||
#### View and edit modes
|
||||
|
||||
View mode renders a static board without executing the drag-and-drop editor.
|
||||
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
|
||||
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
|
||||
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; its settings button remains visible in the compact header above the tile.
|
||||
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
|
||||
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
|
||||
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
|
||||
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
|
||||
Exit edit mode to save the resulting positions.
|
||||
|
||||
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
|
||||
Resize tiles from any edge or corner handle.
|
||||
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
|
||||
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
|
||||
Press Enter, Space, or Escape to stop keyboard editing.
|
||||
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
|
||||
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
Once the minimum of the next layout is reached the layout will change.
|
||||
|
||||
#### Preview
|
||||
|
||||
@@ -169,24 +115,6 @@ The **Move / resize item** action in the edit menu remains available for precise
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Containers and rails
|
||||
|
||||
Containers can hold apps, widgets, and nested Containers.
|
||||
The same Container model works on the main canvas, inside a fixed rail, or inside another Container.
|
||||
Move and resize Containers directly in edit mode; nested content stays within the Container's local bounds.
|
||||
Resize outlines follow the pointer continuously while a dashed preview marks the grid size that will be applied on release.
|
||||
Each responsive layout stores its own Container position and size.
|
||||
|
||||
Each Container can configure:
|
||||
|
||||
- Its label and whether that label is visible.
|
||||
- Whether users can collapse and expand it like an accordion.
|
||||
- Whether its menu shows **Open all in new tabs** for the app links it contains.
|
||||
- Its border color and custom CSS classes.
|
||||
|
||||
Collapsing a Container reduces its full-width header to a half-row expansion bar and compacts the surrounding layout without losing its expanded size.
|
||||
Expanding it smoothly restores those bounds and moves following items out of the way without reloading the widgets inside it.
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
@@ -231,7 +159,7 @@ Change the color of your bookmark / app icons.
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items and Containers.
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ This means you can automate the update of the certificates by simply replacing o
|
||||
Every integration has its own way of obtaining certificates.
|
||||
Generally it is recommended to search for the documentation of the integration you are using.
|
||||
Most of the time you can find the certificate in the file system of the integration.
|
||||
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
|
||||
|
||||
- use `scp user@host:/path/to/file ./certificate.pem`
|
||||
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
|
||||
|
||||
Below you can find instructions for some of the integrations.
|
||||
|
||||
### Pi-hole (v6)
|
||||
@@ -65,3 +70,9 @@ Below you can find instructions for some of the integrations.
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
|
||||
### Proxmox
|
||||
|
||||
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
|
||||
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
|
||||
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
|
||||
@@ -13,6 +13,7 @@ import { appWidget } from ".";
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import temperature from "./img/temperature.png";
|
||||
### Interactions
|
||||
|
||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||
- After a successful toggle, the displayed state refreshes immediately.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -53,4 +53,6 @@ import withDisplayname from "./img/with-displayname.png";
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||
|
||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||
@@ -79,10 +79,6 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
source: "/(.*)", // Apply CSP to all routes
|
||||
headers: [
|
||||
{
|
||||
key: "Accept-CH",
|
||||
value: "Sec-CH-Viewport-Width",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
|
||||
|
||||
@@ -14,12 +14,8 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "catalog:",
|
||||
"@dnd-kit/collision": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
"@dnd-kit/dom": "catalog:",
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/react": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
"@dnd-kit/utilities": "catalog:",
|
||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||
@@ -36,6 +32,7 @@
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "catalog:",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/image-proxy": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
|
||||
+39
-32
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -11,43 +12,38 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
return <AddMenu />;
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
export const BoardAddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: { id: "new", name: "" },
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{ title: (translate) => translate("section.category.create.title") },
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
@@ -60,18 +56,29 @@ const AddMenu = () => {
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal({ boardId: board.id })}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -1,53 +0,0 @@
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
|
||||
--board-gutter-viewport-height: calc(
|
||||
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
|
||||
);
|
||||
|
||||
position: sticky;
|
||||
top: var(--board-gutter-sticky-top);
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"]::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
margin-top: -24px;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 24px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
content: attr(aria-label);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,121 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Box } from "@mantine/core";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
|
||||
if (!currentLayout) throw new Error("Expected the board to contain a layout");
|
||||
|
||||
const mainSection = getRootSectionForLane(board, "main");
|
||||
const leftSection = getRootSectionForLane(board, "left");
|
||||
const rightSection = getRootSectionForLane(board, "right");
|
||||
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
|
||||
|
||||
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
|
||||
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
|
||||
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
@@ -18,7 +21,7 @@ import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
@@ -73,7 +76,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<ClientBoard />
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const BoardLoadingShell = () => (
|
||||
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
data-homarr-dev-benchmark-board-shell
|
||||
height={rows * 96}
|
||||
radius="var(--mantine-radius-default)"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
loading: BoardLoadingShell,
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -19,32 +19,22 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import type * as EditActionsModule from "./_edit-actions";
|
||||
|
||||
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
|
||||
const loadEditActionsAsync = () => {
|
||||
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
|
||||
editActionsModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return editActionsModulePromise;
|
||||
};
|
||||
const loadBoardEditorAsync = async () =>
|
||||
await Promise.all([
|
||||
loadGridEditorAsync(),
|
||||
loadEditActionsAsync(),
|
||||
import("~/components/board/items/item-menu"),
|
||||
import("~/components/board/sections/container/container-menu"),
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
const loadBoardAddMenu = () => import("./_board-add-menu");
|
||||
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
|
||||
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
|
||||
loading: () => (
|
||||
<HeaderButton loading>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
@@ -53,13 +43,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardEditActions />}
|
||||
{isEditMode && <BoardAddMenu />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
@@ -72,11 +62,10 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const commonT = useI18n();
|
||||
const latestBoardRef = useRef(board);
|
||||
|
||||
latestBoardRef.current = board;
|
||||
@@ -105,71 +94,27 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const prepareEditorAsync = useCallback(async () => {
|
||||
if (editorLoadState === "ready") return;
|
||||
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
throw new Error("Unable to load the board editor");
|
||||
}
|
||||
}, [editorLoadState]);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleGridEditorWarmup(async () => {
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prewarmEditor = useCallback(() => {
|
||||
void prepareEditorAsync().catch(() => {
|
||||
// A click or hotkey retries and reports the error.
|
||||
});
|
||||
}, [prepareEditorAsync]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(latestBoardRef.current);
|
||||
}
|
||||
setIsEnteringEditMode(true);
|
||||
try {
|
||||
await prepareEditorAsync();
|
||||
startTransition(open);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.loadError.title"),
|
||||
message: t("notification.loadError.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnteringEditMode(false);
|
||||
}
|
||||
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
||||
open();
|
||||
}, [isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
onFocus={prewarmEditor}
|
||||
onPointerEnter={prewarmEditor}
|
||||
loading={isPending || isEnteringEditMode}
|
||||
onClick={toggle}
|
||||
loading={isPending}
|
||||
data-testid="board-edit-mode-toggle"
|
||||
data-board-editor-preload-state={editorLoadState}
|
||||
aria-busy={isPending || isEnteringEditMode}
|
||||
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
|
||||
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
|
||||
aria-pressed={isEditMode}
|
||||
onFocus={preloadBoardAddMenu}
|
||||
onPointerEnter={preloadBoardAddMenu}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
@@ -178,7 +123,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
@@ -189,7 +133,7 @@ const SelectBoardsMenu = () => {
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardIntegrationProvider } from "./_integration-provider";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
providedIntegrations: undefined as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => {
|
||||
mocks.providedIntegrations = integrations;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board integration provider", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.providedIntegrations = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("provides the permission-filtered server result without a second client query", () => {
|
||||
const initialIntegrations = [{ id: "initial" }];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardIntegrationProvider,
|
||||
{ initialIntegrations: initialIntegrations as never },
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.providedIntegrations).toBe(initialIntegrations);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
|
||||
interface Props {
|
||||
initialIntegrations: RouterOutputs["integration"]["all"];
|
||||
}
|
||||
|
||||
export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren<Props>) => {
|
||||
return <IntegrationProvider integrations={initialIntegrations}>{children}</IntegrationProvider>;
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
.gutterSettings {
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
transition: grid-template-columns 180ms ease-out;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewCanvas > div,
|
||||
.previewGutter {
|
||||
background: var(--mantine-color-default);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(1) {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.previewGutter {
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gutterPreview {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Fieldset,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
@@ -22,7 +8,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./_layout.module.css";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
@@ -42,17 +27,7 @@ export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [
|
||||
...(previous.layouts ?? []),
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
@@ -69,21 +44,7 @@ export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={layout.columnCount}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
onChange={(columnCount) => {
|
||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
||||
}}
|
||||
/>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -94,15 +55,6 @@ export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
@@ -126,123 +78,3 @@ export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface GutterSettingsProps {
|
||||
left: number;
|
||||
right: number;
|
||||
columnCount: number;
|
||||
onLeftChange: (value: number) => void;
|
||||
onRightChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const gutterMarks = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
];
|
||||
|
||||
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
|
||||
const t = useI18n();
|
||||
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("layout.field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<GutterPreview left={left} right={right} columnCount={columnCount} />
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="left"
|
||||
value={left}
|
||||
max={Math.min(maxGutterWidth, columnCount - right - 1)}
|
||||
onChange={onLeftChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="right"
|
||||
value={right}
|
||||
max={Math.min(maxGutterWidth, columnCount - left - 1)}
|
||||
onChange={onRightChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterControl = ({
|
||||
side,
|
||||
value,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
value: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
description={t(`layout.field.gutters.${side}.description`)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={max}
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
|
||||
const t = useI18n();
|
||||
const safeColumnCount = Math.max(1, columnCount);
|
||||
const main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
.filter((track) => track !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
<Box className={classes.previewCanvas}>
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
</Box>
|
||||
{right > 0 && <Box className={classes.previewGutter} data-side="right" />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -8,7 +7,6 @@ import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
@@ -50,7 +48,6 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
(error: unknown) => ({ status: "rejected", error }) as const,
|
||||
);
|
||||
const colorSchemePromise = getCurrentColorSchemeAsync();
|
||||
const initialViewportWidthPromise = getInitialViewportWidthAsync();
|
||||
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!withTour || !session || env.DEMO_MODE) return false;
|
||||
|
||||
@@ -62,12 +59,11 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
|
||||
sessionPromise,
|
||||
initialBoardPromise,
|
||||
colorSchemePromise,
|
||||
shouldRunBoardTourPromise,
|
||||
initialViewportWidthPromise,
|
||||
]);
|
||||
if (initialBoardResult.status === "rejected") {
|
||||
const { error } = initialBoardResult;
|
||||
@@ -88,14 +84,9 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
@@ -121,22 +112,3 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
return Layout;
|
||||
};
|
||||
|
||||
const getInitialViewportWidthAsync = async () => {
|
||||
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
|
||||
const cookieWidth = Number(cookieValue);
|
||||
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
|
||||
return cookieWidth;
|
||||
}
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
|
||||
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
|
||||
return clientHintWidth;
|
||||
}
|
||||
|
||||
const userAgent = requestHeaders.get("user-agent") ?? "";
|
||||
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
|
||||
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
|
||||
return 1440;
|
||||
};
|
||||
@@ -7,9 +7,10 @@ export type Item = Board["items"][number];
|
||||
export type ItemLayout = Item["layouts"][number];
|
||||
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
|
||||
|
||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
export type ContainerSection = Extract<Section, { kind: "container" }>;
|
||||
export type ContainerSectionLayout = ContainerSection["layouts"][number];
|
||||
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
|
||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||
export type DynamicSectionLayout = DynamicSection["layouts"][number];
|
||||
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
|
||||
|
||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||
@@ -32,7 +32,6 @@ import { env } from "~/env";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { ViewportHint } from "~/components/layout/viewport-hint";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
@@ -167,7 +166,6 @@ export default async function Layout(props: {
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<ViewportHint />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -29,6 +29,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
import { serializeMcpToolResult } from "../_serialize-result";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
@@ -340,7 +341,7 @@ const mcpHandler = createMcpHandler(
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -21,21 +19,17 @@ export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: itemLayouts,
|
||||
layouts: createItemLayouts(previous, firstSection, kind),
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -66,14 +60,9 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -81,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -17,17 +15,16 @@ export const duplicateItemCallback =
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
const clonedLayouts = clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedLayouts,
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -45,10 +42,8 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -72,21 +67,18 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
|
||||
|
||||
const size = {
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
});
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||
const containersOfSection = board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
const dynamicSectionsOfFirstSection = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||
.filter((section) => section.parentSectionId === sectionId);
|
||||
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
|
||||
}))
|
||||
.filter((item) => item.sectionId === sectionId);
|
||||
|
||||
return [...items, ...containersOfSection];
|
||||
return [...items, ...dynamicSectionsOfFirstSection];
|
||||
};
|
||||
@@ -1,19 +1,15 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
@@ -56,9 +52,9 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
@@ -67,21 +63,21 @@ describe("item actions create-item", () => {
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||
.build();
|
||||
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||
.build();
|
||||
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.addSection(containerInFirstSection)
|
||||
.addSection(otherContainer)
|
||||
.addSection(dynamicSectionInFirstSection)
|
||||
.addSection(otherDynamicSection)
|
||||
.addItem(itemInFirstSection)
|
||||
.addItem(otherItem)
|
||||
.build();
|
||||
@@ -98,10 +94,8 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -110,35 +104,8 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
undefined,
|
||||
9999,
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
|
||||
const created = result.items.find((item) => item.id === "created");
|
||||
|
||||
expect(created?.layouts).toEqual([
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,10 +7,6 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should copy it in the same section", () => {
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
@@ -65,28 +61,4 @@ describe("item actions duplicate-item", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the next automatic canvas row when it is the first available space", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
const currentItem = new ItemMockBuilder({ id: "source" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build();
|
||||
board.items.push(currentItem);
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
const duplicated = result.items.find((item) => item.id !== currentItem.id);
|
||||
|
||||
expect(duplicated?.layouts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("item move targets", () => {
|
||||
test("offers the canvas, enabled gutters, and nested sections", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 6;
|
||||
layout.leftGutterColumnCount = 1;
|
||||
layout.rightGutterColumnCount = 1;
|
||||
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new ContainerSectionMockBuilder({
|
||||
id: "container",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{
|
||||
id: "weather",
|
||||
type: "item",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
container: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets.map((target) => target.label)).toEqual([
|
||||
"Dashboard canvas, destination 1",
|
||||
"Left dashboard rail, destination 2",
|
||||
"Right dashboard rail, destination 3",
|
||||
"New section, destination 4",
|
||||
]);
|
||||
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
|
||||
});
|
||||
|
||||
test("does not offer canvases that are narrower than a nested section's contents", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new ContainerSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas" }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
section,
|
||||
);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "nested" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
sectionId: section.id,
|
||||
xOffset: 2,
|
||||
width: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const sourceLayout = section.layouts[0];
|
||||
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
container: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||
import { ItemMockBuilder } from "./item-mock";
|
||||
import { LayoutMockBuilder } from "./layout-mock";
|
||||
@@ -45,8 +45,6 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
@@ -58,8 +56,8 @@ export class BoardMockBuilder {
|
||||
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||
}
|
||||
|
||||
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
|
||||
return this.addSection(new ContainerSectionMockBuilder(container).build());
|
||||
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||
}
|
||||
|
||||
public addSection(section: Section): BoardMockBuilder {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class CategorySectionMockBuilder {
|
||||
private readonly section: CategorySection;
|
||||
|
||||
constructor(section?: Partial<CategorySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
...section,
|
||||
} satisfies CategorySection;
|
||||
}
|
||||
|
||||
public build(): CategorySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
|
||||
options?: Partial<ContainerSection["options"]>;
|
||||
};
|
||||
|
||||
export class ContainerSectionMockBuilder {
|
||||
private readonly section: ContainerSection;
|
||||
|
||||
constructor(section?: ContainerSectionMock) {
|
||||
this.section = {
|
||||
id: section?.id ?? createId(),
|
||||
kind: "container",
|
||||
collapsed: section?.collapsed ?? false,
|
||||
layouts: section?.layouts ?? [],
|
||||
options: containerSectionOptionsSchema.parse(section?.options),
|
||||
};
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: layout?.layoutId ?? createId(),
|
||||
parentSectionId: layout?.parentSectionId ?? createId(),
|
||||
xOffset: layout?.xOffset ?? 0,
|
||||
yOffset: layout?.yOffset ?? 0,
|
||||
width: layout?.width ?? 1,
|
||||
height: layout?.height ?? 1,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContainerSection {
|
||||
return structuredClone(this.section);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: Partial<DynamicSection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: "1",
|
||||
height: 1,
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
parentSectionId: "0",
|
||||
...layout,
|
||||
} satisfies DynamicSection["layouts"][0]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): DynamicSection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
|
||||
@@ -10,17 +10,3 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] * {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
@@ -22,22 +21,18 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
if (kind === "iframe") return "hidden";
|
||||
if (kind === "systemResources") return "visible";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
@@ -52,13 +47,10 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
@@ -66,7 +58,7 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
@@ -150,7 +142,6 @@ interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definitio
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
@@ -159,14 +150,8 @@ const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: Loaded
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -210,16 +195,13 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={newItem} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
<BoardItemMenu
|
||||
offset={4}
|
||||
item={newItem}
|
||||
definition={definition}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -231,13 +213,8 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu item={newItem} definition={definition} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
data-homarr-widget-ready={item.kind}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
@@ -12,10 +13,11 @@ import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
@@ -29,21 +31,25 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: isMenuOpen && hasSupportedIntegrations,
|
||||
});
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -78,7 +84,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -89,8 +95,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -111,16 +117,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={12}
|
||||
right={8}
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: 10 }}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size="1rem" />
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
@@ -130,16 +134,17 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
|
||||
@@ -1,220 +1,94 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | ContainerSectionItem,
|
||||
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
|
||||
>;
|
||||
|
||||
export interface OpenItemMoveModalInput {
|
||||
entry: MovableEntry;
|
||||
sourceSectionId: string;
|
||||
}
|
||||
|
||||
interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
export interface ItemMoveModalProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
container: string;
|
||||
leftRail: string;
|
||||
rightRail: string;
|
||||
numbered: (name: string, index: number) => string;
|
||||
located: (name: string, location: string, index: number) => string;
|
||||
}
|
||||
|
||||
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
name: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const tLandmark = useScopedI18n("board.landmark");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tMoveResize = useScopedI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
|
||||
if (!initialTarget) {
|
||||
throw new Error("No valid dashboard section is available for this item");
|
||||
}
|
||||
|
||||
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
sectionId: z.string().min(1),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(32767),
|
||||
height: z.number().int().min(1).max(32767),
|
||||
xOffset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(innerProps.columnCount - 1),
|
||||
yOffset: z.number().min(0),
|
||||
width: z.number().min(1).max(maxWidthRef.current),
|
||||
height: z.number().min(1),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
xOffset: innerProps.item.xOffset,
|
||||
yOffset: innerProps.item.yOffset,
|
||||
width: innerProps.item.width,
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.xOffset !== previous.xOffset) {
|
||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
|
||||
const target = targetById.get(values.sectionId);
|
||||
if (!target) return;
|
||||
|
||||
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
|
||||
const height = Math.max(minimumSize.height, values.height);
|
||||
const nextPlacement: SectionGridPlacement = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Math.min(values.xOffset, target.columnCount - width),
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
if (!gridItem) return;
|
||||
innerProps.gridStack.update(gridItem, {
|
||||
h: values.height,
|
||||
w: values.width,
|
||||
x: values.xOffset,
|
||||
y: values.yOffset,
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = resolvePinnedGridCollisions(
|
||||
[...targetPlacements, nextPlacement],
|
||||
nextPlacement,
|
||||
target.columnCount,
|
||||
);
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", tMoveResize("keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
});
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[
|
||||
actions,
|
||||
board,
|
||||
commitSectionGrids,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
form,
|
||||
minimumSize,
|
||||
sourceSectionId,
|
||||
targetById,
|
||||
tMoveResize,
|
||||
],
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={tMoveResize("field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={tMoveResize("field.xOffset.label")}
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
min={0}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
max={innerProps.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={tMoveResize("field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={tMoveResize("field.width.label")}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={tMoveResize("field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -227,137 +101,3 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
return useCallback(
|
||||
(input: OpenItemMoveModalInput) => {
|
||||
openModal({
|
||||
...input,
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
export const getMoveTargets = (
|
||||
board: Board,
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return [];
|
||||
if (
|
||||
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
|
||||
minimum.width > layout.width ||
|
||||
minimum.height > layout.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.options.title || labels.container,
|
||||
columnCount: layout.width,
|
||||
maxRowCount: layout.height,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return candidates.map(({ name, location, ...target }, index) => ({
|
||||
...target,
|
||||
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
|
||||
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return columnCount;
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionGridPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
|
||||
let current: string | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current && !visited.has(current)) {
|
||||
if (current === ancestorId) return true;
|
||||
visited.add(current);
|
||||
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
|
||||
if (!section || section.kind !== "container") return false;
|
||||
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusMovedEntry = (entryId: string, sectionId: string) => {
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
|
||||
(candidate) => candidate.dataset.gridSectionId === sectionId,
|
||||
);
|
||||
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
|
||||
(candidate) => candidate.dataset.gridItemId === entryId,
|
||||
);
|
||||
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
|
||||
if (editorEntry) {
|
||||
editorEntry.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts < 10) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { ItemMoveModalProps } from "./item-move-modal";
|
||||
|
||||
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
|
||||
|
||||
const loadItemMoveModal = () => {
|
||||
if (itemMoveModalPromise) return itemMoveModalPromise;
|
||||
|
||||
const currentPromise = import("./item-move-modal");
|
||||
itemMoveModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
|
||||
|
||||
interface LazyItemMoveModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: ItemMoveModalProps;
|
||||
}
|
||||
|
||||
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
|
||||
const { ItemMoveModal } = use(loadItemMoveModal());
|
||||
const Component = ItemMoveModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
itemMoveModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyItemMoveModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.moveResize.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -3,7 +3,15 @@
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -12,7 +20,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -22,7 +30,9 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
@@ -37,7 +47,10 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
|
||||
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
|
||||
if (isLinkWidget && !isEditMode) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
@@ -87,6 +100,7 @@ type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> &
|
||||
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const tItem = useScopedI18n("item");
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
@@ -94,11 +108,15 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
@@ -174,7 +192,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
);
|
||||
}
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -252,8 +270,35 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
{canConfigureWidget && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
@@ -275,11 +320,31 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -31,7 +32,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 36px 4px 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
|
||||
@@ -41,7 +41,7 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} data-board-widget-header>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.nameSection}>
|
||||
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Fixed dashboard geometry
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable. The visual canvas always fits the available width, every root lane
|
||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||
as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
|
||||
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
|
||||
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
|
||||
4. position children with `getLogicalItemStyle`;
|
||||
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
|
||||
|
||||
Edit mode can use any interaction library, but its final coordinates should be
|
||||
normalized through this core. `placeGridItem` is the deterministic
|
||||
keyboard/numeric fallback: the active item is pinned while collisions move out
|
||||
of the way and all other items compact vertically. Pointer coordinates from a
|
||||
scaled editor can be converted with `toLogicalCanvasPoint`.
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||
import { getCollapsedDisplayLayout } from "./reflow";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
export { getBoardLaneColumnCount };
|
||||
|
||||
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
|
||||
const roots = board.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) {
|
||||
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
}
|
||||
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) return 0;
|
||||
|
||||
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
const root = getRootSectionForLane(board, lane);
|
||||
if (columnCount === 0 || !root) return [];
|
||||
|
||||
const placements: GridPlacement[] = [
|
||||
...board.items.flatMap((item) => {
|
||||
const itemLayout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
|
||||
);
|
||||
return itemLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: itemLayout.xOffset,
|
||||
y: itemLayout.yOffset,
|
||||
w: itemLayout.width,
|
||||
h: itemLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
return sectionLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: sectionLayout.xOffset,
|
||||
y: sectionLayout.yOffset,
|
||||
w: sectionLayout.width,
|
||||
h: sectionLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
const collapsedSectionIds = new Set(
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "container" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
)
|
||||
.map((section) => section.id),
|
||||
);
|
||||
const displayPlacements =
|
||||
collapsedSectionIds.size > 0
|
||||
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
|
||||
: placements;
|
||||
|
||||
return [getLayoutRowCount(displayPlacements)];
|
||||
});
|
||||
|
||||
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
|
||||
* individual cards never change their internal layout at different viewports.
|
||||
*/
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 24;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import type { GridCoordinates, GridPlacement } from "./types";
|
||||
|
||||
export const getLogicalTrackSize = (trackCount: number) => {
|
||||
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
|
||||
if (normalizedTrackCount === 0) return 0;
|
||||
|
||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||
};
|
||||
|
||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||
position: "absolute",
|
||||
left: x * LOGICAL_GRID_PITCH,
|
||||
top: y * LOGICAL_GRID_PITCH,
|
||||
width: getLogicalTrackSize(w),
|
||||
height: getLogicalTrackSize(h),
|
||||
});
|
||||
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
|
||||
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
||||
|
||||
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
|
||||
};
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement => {
|
||||
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
|
||||
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
|
||||
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
|
||||
|
||||
return {
|
||||
...placement,
|
||||
x,
|
||||
y: toNonNegativeInteger(placement.y),
|
||||
w: width,
|
||||
h: toPositiveInteger(placement.h, "placement.h"),
|
||||
};
|
||||
};
|
||||
|
||||
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
const integer = Math.trunc(toFiniteNumber(value, name));
|
||||
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
|
||||
|
||||
return integer;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./constants";
|
||||
export * from "./board-lanes";
|
||||
export * from "./geometry";
|
||||
export * from "./reflow";
|
||||
export * from "./semantics";
|
||||
export * from "./types";
|
||||
@@ -1,207 +0,0 @@
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
|
||||
import { normalizeGridPlacement } from "./geometry";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReflowOptions {
|
||||
columnCount: number;
|
||||
/**
|
||||
* A pinned item keeps its requested x/y while every other item compacts
|
||||
* around it. Use this for the item currently being moved or resized.
|
||||
*/
|
||||
pinnedItemId?: string;
|
||||
}
|
||||
|
||||
interface CollapsedLayoutOptions {
|
||||
columnCount: number;
|
||||
collapsedItemIds: ReadonlySet<string>;
|
||||
collapsedRowCount?: number;
|
||||
}
|
||||
|
||||
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
|
||||
/**
|
||||
* Resolves overlaps and vertically compacts items. Ordering is based on the
|
||||
* persisted position and stable id, so the result does not depend on React or
|
||||
* database array order.
|
||||
*/
|
||||
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, pinnedItemId }: ReflowOptions,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(placements);
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
|
||||
|
||||
if (pinnedItemId && !pinned) {
|
||||
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
|
||||
}
|
||||
|
||||
const placed: TPlacement[] = pinned ? [pinned] : [];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRow(candidate, placed),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces the view-only layout after inline containers are collapsed. Only
|
||||
* display geometry changes; callers retain the persisted expanded height.
|
||||
*/
|
||||
export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
|
||||
): TPlacement[] => {
|
||||
if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) {
|
||||
throw new RangeError("collapsedRowCount must be positive and finite");
|
||||
}
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const displayPlacements = normalized.map((placement) =>
|
||||
collapsedItemIds.has(placement.id)
|
||||
? {
|
||||
...placement,
|
||||
h: collapsedRowCount,
|
||||
}
|
||||
: placement,
|
||||
);
|
||||
|
||||
return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the requested placement and pins it while neighbors compact into
|
||||
* free space or move down. This is the deterministic fallback used by
|
||||
* keyboard/numeric editing and can also validate a drag library result.
|
||||
*/
|
||||
export const placeGridItem = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
nextPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
) => {
|
||||
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
|
||||
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
|
||||
{
|
||||
columnCount,
|
||||
pinnedItemId: nextPlacement.id,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
) => placements.toSorted(comparePlacements);
|
||||
|
||||
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
|
||||
let y = 0;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const reflowCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
expandedPlacements: readonly TPlacement[],
|
||||
displayPlacements: readonly TPlacement[],
|
||||
collapsedItemIds: ReadonlySet<string>,
|
||||
collapsedRowCount: number,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(expandedPlacements);
|
||||
const displayById = new Map(displayPlacements.map((placement) => [placement.id, placement]));
|
||||
const collapsedPlacements = expandedPlacements.filter((placement) => collapsedItemIds.has(placement.id));
|
||||
const placed: TPlacement[] = [];
|
||||
|
||||
for (const expandedPlacement of expandedPlacements.toSorted(comparePlacements)) {
|
||||
const candidate = getRequiredPlacement(displayById, expandedPlacement.id);
|
||||
const verticalShift = getCollapsedVerticalShift(expandedPlacement, collapsedPlacements, collapsedRowCount);
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRowAtOrAfter(candidate, placed, Math.max(0, candidate.y - verticalShift)),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return displayPlacements.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
const getCollapsedVerticalShift = (
|
||||
candidate: GridPlacement,
|
||||
collapsedPlacements: readonly GridPlacement[],
|
||||
collapsedRowCount: number,
|
||||
) => {
|
||||
const removedIntervals = collapsedPlacements
|
||||
.filter(
|
||||
(collapsed) =>
|
||||
collapsed.id !== candidate.id &&
|
||||
collapsed.y + collapsed.h <= candidate.y &&
|
||||
doGridPlacementsOverlapHorizontally(candidate, collapsed),
|
||||
)
|
||||
.map((collapsed) => ({ start: collapsed.y + collapsedRowCount, end: collapsed.y + collapsed.h }))
|
||||
.toSorted((first, second) => first.start - second.start || first.end - second.end);
|
||||
|
||||
let shift = 0;
|
||||
let intervalEnd = Number.NEGATIVE_INFINITY;
|
||||
for (const interval of removedIntervals) {
|
||||
const start = Math.max(interval.start, intervalEnd);
|
||||
if (interval.end > start) shift += interval.end - start;
|
||||
intervalEnd = Math.max(intervalEnd, interval.end);
|
||||
}
|
||||
return shift;
|
||||
};
|
||||
|
||||
const doGridPlacementsOverlapHorizontally = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w && first.x + first.w > second.x;
|
||||
|
||||
const findFirstAvailableRowAtOrAfter = (
|
||||
candidate: GridPlacement,
|
||||
placed: readonly GridPlacement[],
|
||||
minimumRow: number,
|
||||
) => {
|
||||
let y = minimumRow;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
|
||||
|
||||
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
|
||||
const ids = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
ids.add(placement.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getRequiredPlacement = <TPlacement extends GridPlacement>(
|
||||
placements: ReadonlyMap<string, TPlacement>,
|
||||
id: string,
|
||||
) => {
|
||||
const placement = placements.get(id);
|
||||
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
|
||||
|
||||
return placement;
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
|
||||
/*
|
||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return availableWidth / logicalWidth;
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
||||
};
|
||||
|
||||
interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The complete canvas always fits its viewport so board
|
||||
* content can never widen the document or require horizontal scrolling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const scale = useMemo(
|
||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
ref={viewportRef}
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-initial-height={initialLogicalHeight}
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReadonlyCanvasAttributesInput {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
interface EditableGridCellAttributesInput {
|
||||
label: string;
|
||||
placement: GridPlacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode is a labelled region, not an ARIA grid. It keeps the native
|
||||
* semantics and focus behavior of links, buttons and widget controls inside.
|
||||
*/
|
||||
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-readonly": "true",
|
||||
}) as const;
|
||||
|
||||
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
|
||||
({
|
||||
"data-grid-id": placement.id,
|
||||
"data-grid-x": placement.x,
|
||||
"data-grid-y": placement.y,
|
||||
"data-grid-w": placement.w,
|
||||
"data-grid-h": placement.h,
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
|
||||
* data grid: arrow keys edit geometry rather than navigate rows and columns.
|
||||
* Each tile is one focusable direct-manipulation surface, while controls inside
|
||||
* it keep their native semantics and tab order.
|
||||
*/
|
||||
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-editable": "true",
|
||||
}) as const;
|
||||
|
||||
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
|
||||
({
|
||||
role: "group",
|
||||
"aria-label": label,
|
||||
tabIndex: 0,
|
||||
...getReadonlyGridItemAttributes(placement),
|
||||
}) as const;
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps invalid gutters while preserving one main column", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 4,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects ambiguous duplicate roots", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
|
||||
.build();
|
||||
|
||||
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
|
||||
});
|
||||
|
||||
test("calculates a stable server height from the tallest visible lane", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const containerSectionId = "container";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({
|
||||
id: containerSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
.addLayout({
|
||||
layoutId,
|
||||
parentSectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 3,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: leftSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 4,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
board.layouts = [
|
||||
{
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 8,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
normalizeGridPlacement,
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes invalid bounds and derives the required row count", () => {
|
||||
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
|
||||
|
||||
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
|
||||
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
|
||||
});
|
||||
|
||||
test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
|
||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
doGridPlacementsOverlap,
|
||||
getCollapsedDisplayLayout,
|
||||
placeGridItem,
|
||||
reflowVerticalLayout,
|
||||
sortGridPlacementsForReadingOrder,
|
||||
} from "../index";
|
||||
import type { GridPlacement } from "../index";
|
||||
|
||||
describe("dashboard layout reflow", () => {
|
||||
test("compacts each horizontal lane and removes collisions", () => {
|
||||
const result = reflowVerticalLayout(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
|
||||
],
|
||||
{ columnCount: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("shrinks a collapsed inline container without compacting unrelated columns", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
],
|
||||
{
|
||||
columnCount: 3,
|
||||
collapsedItemIds: new Set(["section"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves intentional gaps below a collapsed container", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 6, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["section"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 3.5, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not double-count side-by-side collapsed space", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 3 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["left", "right"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("pins a moved or expanded item and pushes collisions out of the way", () => {
|
||||
const result = placeGridItem(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
|
||||
],
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("is deterministic regardless of input array order", () => {
|
||||
const first = [
|
||||
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
|
||||
];
|
||||
const second = first.toReversed();
|
||||
|
||||
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
|
||||
byId(reflowVerticalLayout(second, { columnCount: 2 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("provides row-major DOM order for the read-only renderer", () => {
|
||||
const items = [
|
||||
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
|
||||
});
|
||||
});
|
||||
|
||||
const hasOverlap = (placements: readonly GridPlacement[]) =>
|
||||
placements.some((placement, index) =>
|
||||
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
|
||||
);
|
||||
|
||||
const byId = (placements: readonly GridPlacement[]) =>
|
||||
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
expect(1000 * scale).toBe(840);
|
||||
});
|
||||
|
||||
test("scales up uniformly on a larger screen", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("keeps fitting narrow viewports without horizontal overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(0.6);
|
||||
expect(logicalWidth * scale).toBe(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps the board fitted at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBeCloseTo(normalScale / 2);
|
||||
expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[0, 1000],
|
||||
[1000, 0],
|
||||
[Number.NaN, 1000],
|
||||
[1000, Number.POSITIVE_INFINITY],
|
||||
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
|
||||
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
|
||||
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
|
||||
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
|
||||
expect(calculateBoardUiScale(1)).toBe(1);
|
||||
expect(calculateBoardUiScale(1.5)).toBe(1);
|
||||
});
|
||||
|
||||
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"uses stable UI density for an invalid canvas scale (%s)",
|
||||
(scale) => {
|
||||
expect(calculateBoardUiScale(scale)).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getEditableCanvasAttributes,
|
||||
getEditableGridCellAttributes,
|
||||
getReadonlyCanvasAttributes,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "../index";
|
||||
|
||||
describe("dashboard layout semantics", () => {
|
||||
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
|
||||
|
||||
test("keeps read-only mode a native labelled region", () => {
|
||||
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Home dashboard",
|
||||
"data-grid-readonly": "true",
|
||||
});
|
||||
expect(getReadonlyGridItemAttributes(placement)).toEqual({
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps edit mode a labelled region with spatial item metadata", () => {
|
||||
expect(
|
||||
getEditableCanvasAttributes({
|
||||
label: "Edit home dashboard",
|
||||
columnCount: 12,
|
||||
rowCount: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Edit home dashboard",
|
||||
"data-grid-editable": "true",
|
||||
});
|
||||
|
||||
expect(
|
||||
getEditableGridCellAttributes({
|
||||
label: "Weather widget",
|
||||
placement,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "group",
|
||||
"aria-label": "Weather widget",
|
||||
tabIndex: 0,
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface GridCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridPlacement extends GridCoordinates {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const initialCollapsed =
|
||||
typeof window !== "undefined"
|
||||
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||
: section.collapsed;
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||
onOpen() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "true");
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "false");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
<Group wrap="nowrap">
|
||||
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse expanded={opened} p="sm" pt={0}>
|
||||
<GridStack section={section} />
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveCategoryInput {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export const moveCategoryCallback =
|
||||
(input: MoveCategoryInput) =>
|
||||
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === 1 && input.direction === "up") {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty") {
|
||||
return section;
|
||||
}
|
||||
const offset = input.direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
section.yOffset === currentCategory.yOffset + offset ||
|
||||
section.yOffset === currentCategory.yOffset + offset + 1
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset - offset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
|
||||
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeCategoryCallback =
|
||||
(input: RemoveCategoryInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const emptySectionsAbove = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||
);
|
||||
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||
|
||||
const emptySectionsBelow = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||
);
|
||||
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||
|
||||
if (!aboveSection || !removedSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: aboveSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: currentCategory.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections
|
||||
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
|
||||
.map((section) =>
|
||||
section.kind === "dynamic"
|
||||
? {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset =
|
||||
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
if (layout.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
}
|
||||
: section,
|
||||
),
|
||||
|
||||
items: previous.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
if (layout.sectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.sectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
|
||||
sections: Section[],
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) => {
|
||||
return sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.parentSectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForItemLayouts = (
|
||||
board: Board,
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) =>
|
||||
board.items
|
||||
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.sectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { moveCategoryCallback } from "../move-category";
|
||||
|
||||
describe("Move Category", () => {
|
||||
test.each([
|
||||
[3, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[5, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category up", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test.each([
|
||||
[1, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[3, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category down", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test("should not move category up if it is at the top", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "1",
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
test("should not move category down if it is at the bottom", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "5",
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (categoryCount: number) => {
|
||||
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
|
||||
id: index.toString(),
|
||||
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
|
||||
name: `Category ${index}`,
|
||||
yOffset: index,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
items: [],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
|
||||
const sortSections = (sections: Section[]) => {
|
||||
return sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
};
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { removeCategoryCallback } from "../remove-category";
|
||||
|
||||
describe("Remove Category", () => {
|
||||
test.each([
|
||||
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const input = removeId.toString();
|
||||
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems(initialYOffsets, layoutId))
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: input })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||
expectedRemovals.forEach((expectedRemoval) => {
|
||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||
});
|
||||
const aboveSectionItems = result.items.filter(
|
||||
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
|
||||
);
|
||||
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
|
||||
},
|
||||
);
|
||||
|
||||
test("should correctly move items to above empty section", () => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const sectionIds = {
|
||||
above: "2",
|
||||
category: "3",
|
||||
below: "4",
|
||||
dynamic: "7",
|
||||
};
|
||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
|
||||
.addDynamicSection(
|
||||
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
|
||||
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "above-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: sectionIds.category })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
|
||||
expect(aboveSectionItems.length).toBe(6);
|
||||
|
||||
expect(
|
||||
aboveSectionItems
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...item.layouts[0]!,
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]);
|
||||
|
||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
|
||||
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
return initialYOffsets.map((yOffset, index) =>
|
||||
index % 2 === 0
|
||||
? new EmptySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build()
|
||||
: new CategorySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build(),
|
||||
) satisfies Section[];
|
||||
};
|
||||
|
||||
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
|
||||
return initialYOffsets.map((yOffset) =>
|
||||
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
import { removeCategoryCallback } from "./actions/remove-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
yOffset: number;
|
||||
}
|
||||
|
||||
interface RenameCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addCategory = useCallback(
|
||||
({ name, yOffset }: AddCategory) => {
|
||||
if (yOffset <= -1) {
|
||||
return;
|
||||
}
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: [
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
|
||||
),
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: yOffset + 1,
|
||||
xOffset: 0,
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
yOffset: section.yOffset + 2,
|
||||
})),
|
||||
...previous.sections.filter((section) => section.kind !== "category" && section.kind !== "empty"),
|
||||
] satisfies Section[],
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const addCategoryToEnd = useCallback(
|
||||
({ name }: { name: string }) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
const lastYOffset = lastSection.yOffset;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections,
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset: lastYOffset + 1,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: lastYOffset + 2,
|
||||
xOffset: 0,
|
||||
},
|
||||
] satisfies Section[],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const renameCategory = useCallback(
|
||||
({ id: categoryId, name }: RenameCategory) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category") return section;
|
||||
if (section.id !== categoryId) return section;
|
||||
return {
|
||||
...section,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
(input: MoveCategoryInput) => {
|
||||
updateBoard(moveCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
(input: RemoveCategoryInput) => {
|
||||
updateBoard(removeCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addCategory,
|
||||
addCategoryToEnd,
|
||||
renameCategory,
|
||||
moveCategory,
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
submitLabel: string;
|
||||
category: Category;
|
||||
onSuccess: (category: Category) => void;
|
||||
}
|
||||
|
||||
export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(z.object({ name: z.string().min(1) }), {
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: values.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
import { filterByItemKind } from "./filter";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openModal } = useModalAction(CategoryEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const createCategoryAtYOffset = useCallback(
|
||||
(position: number) => {
|
||||
openModal(
|
||||
{
|
||||
category: {
|
||||
id: createId(),
|
||||
name: t("section.category.create.title"),
|
||||
},
|
||||
onSuccess: (category) => {
|
||||
addCategory({
|
||||
name: category.name,
|
||||
yOffset: position,
|
||||
});
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[addCategory, t, openModal],
|
||||
);
|
||||
|
||||
// creates a new category above the current
|
||||
const addCategoryAbove = useCallback(() => {
|
||||
const aboveYOffset = category.yOffset;
|
||||
createCategoryAtYOffset(aboveYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
// creates a new category below the current
|
||||
const addCategoryBelow = useCallback(() => {
|
||||
const belowYOffset = category.yOffset + 2;
|
||||
createCategoryAtYOffset(belowYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
const moveCategoryUp = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "up",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
const moveCategoryDown = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "down",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
// Removes the current category
|
||||
const remove = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("section.category.remove.title"),
|
||||
children: t("section.category.remove.message", {
|
||||
name: category.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
removeCategory({
|
||||
id: category.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
|
||||
|
||||
const edit = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
category,
|
||||
submitLabel: t("section.category.edit.submit"),
|
||||
onSuccess: (category) => {
|
||||
renameCategory({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.edit.title"),
|
||||
},
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const currentLayoutId = getCurrentLayout(board);
|
||||
const appIds = (
|
||||
await filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
)
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
|
||||
for (const app of appsWithUrls) {
|
||||
const openedWindow = window.open(app.href ?? undefined);
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, board, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
remove,
|
||||
edit,
|
||||
openAllInNewTabs,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconTransitionBottom,
|
||||
IconTransitionTop,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
category: CategorySection;
|
||||
}
|
||||
|
||||
export const CategoryMenu = ({ category }: Props) => {
|
||||
const actions = useActions(category);
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) => {
|
||||
return (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const useActions = (category: CategorySection) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editModeActions = useEditModeActions(category);
|
||||
const nonEditModeActions = useNonEditModeActions(category);
|
||||
|
||||
return useMemo(
|
||||
() => (isEditMode ? editModeActions : nonEditModeActions),
|
||||
[isEditMode, editModeActions, nonEditModeActions],
|
||||
);
|
||||
};
|
||||
|
||||
const useEditModeActions = (category: CategorySection) => {
|
||||
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
|
||||
useCategoryMenuActions(category);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: IconEdit,
|
||||
label: "action.edit",
|
||||
onClick: edit,
|
||||
},
|
||||
{
|
||||
icon: IconTrash,
|
||||
color: "red",
|
||||
label: "action.remove",
|
||||
onClick: remove,
|
||||
},
|
||||
{
|
||||
group: "menu.label.changePosition",
|
||||
icon: IconTransitionTop,
|
||||
label: "action.moveUp",
|
||||
onClick: moveCategoryUp,
|
||||
},
|
||||
{
|
||||
icon: IconTransitionBottom,
|
||||
label: "action.moveDown",
|
||||
onClick: moveCategoryDown,
|
||||
},
|
||||
{
|
||||
group: "menu.label.create",
|
||||
icon: IconRowInsertTop,
|
||||
label: "action.createAbove",
|
||||
onClick: addCategoryAbove,
|
||||
},
|
||||
{
|
||||
icon: IconRowInsertBottom,
|
||||
label: "action.createBelow",
|
||||
onClick: addCategoryBelow,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
const useNonEditModeActions = (category: CategorySection) => {
|
||||
const { openAllInNewTabs } = useCategoryMenuActions(category);
|
||||
return [
|
||||
{
|
||||
icon: IconExternalLink,
|
||||
label: "action.openAllInNewTabs",
|
||||
onClick: openAllInNewTabs,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
interface ActionDefinition {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
onClick: () => MaybePromise<void>;
|
||||
color?: string;
|
||||
group?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const BoardContainerMenu = dynamic(
|
||||
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: ContainerSectionItem;
|
||||
}
|
||||
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.container");
|
||||
const tAll = useI18n();
|
||||
const options = section.options;
|
||||
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
|
||||
section.id,
|
||||
options.showOpenAll && !isEditMode,
|
||||
);
|
||||
const { isVisuallyCollapsed, toggle } = useSectionCollapse({
|
||||
sectionId: section.id,
|
||||
collapsible: options.collapsible,
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-board-container-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.collapsible && !isEditMode && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={
|
||||
options.showLabel ? (
|
||||
isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
|
||||
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{options.showLabel ? (
|
||||
label
|
||||
) : isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
radius="sm"
|
||||
variant="default"
|
||||
c="var(--mantine-color-text)"
|
||||
style={{
|
||||
zIndex: 9,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "var(--background-color)",
|
||||
borderColor: "var(--border-color)",
|
||||
}}
|
||||
title={options.title}
|
||||
data-board-container-label
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
right={8}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
loading={areAppsLoading}
|
||||
onClick={openAllInNewTabs}
|
||||
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Box
|
||||
id={contentId}
|
||||
className={classes.containerBody}
|
||||
h="100%"
|
||||
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
aria-hidden={isVisuallyCollapsed}
|
||||
inert={isVisuallyCollapsed}
|
||||
>
|
||||
<SectionGrid section={section} columnCount={section.width} requestedRowCount={section.height} label={label} />
|
||||
</Box>
|
||||
</Card>
|
||||
{isEditMode && <BoardContainerMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addContainerCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const sectionLayouts = createContainerLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: containerSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies ContainerSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : 1;
|
||||
const size = {
|
||||
width: Math.min(4, columnCount),
|
||||
height: 3,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) throw new Error("Your board is full");
|
||||
|
||||
return {
|
||||
...size,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { addContainerCallback } from "./add-container";
|
||||
import { removeContainerCallback } from "./remove-container";
|
||||
|
||||
describe("container actions", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("creates a container in the first main root for every responsive layout", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 2;
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
|
||||
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
|
||||
|
||||
const result = addContainerCallback()(board);
|
||||
const created = result.sections.find((section) => section.kind === "container");
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId: "layout",
|
||||
parentSectionId: "root",
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 2,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("removes a container and flattens its direct children into the parent", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addSection(child)
|
||||
.addItem(nestedItem)
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const flattenedChild = result.sections.find(
|
||||
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
|
||||
);
|
||||
|
||||
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
|
||||
expect(flattenedChild?.layouts[0]).toMatchObject({
|
||||
parentSectionId: "root",
|
||||
xOffset: 4,
|
||||
yOffset: 6,
|
||||
});
|
||||
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
|
||||
sectionId: "root",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
const existing = new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childA = new ItemMockBuilder({ id: "a" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childB = new ItemMockBuilder({ id: "b" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addItems([existing, childA, childB])
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 4;
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const layouts = Object.fromEntries(
|
||||
result.items.map((item) => {
|
||||
const itemLayout = item.layouts[0];
|
||||
if (!itemLayout) throw new Error("Expected an item layout");
|
||||
return [item.id, itemLayout];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
|
||||
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
|
||||
});
|
||||
|
||||
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when flattening would move a child back into the removed container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when its destination is a child container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
import {
|
||||
getSectionGridColumnCount,
|
||||
getSectionGridPlacements,
|
||||
} from "~/components/board/sections/grid/section-grid-placements";
|
||||
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface RemoveContainerInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeContainerCallback =
|
||||
({ id }: RemoveContainerInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is ContainerSection => section.id === id && section.kind === "container",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
|
||||
const childLayoutIds = [
|
||||
...board.sections.flatMap((section) =>
|
||||
section.kind === "container"
|
||||
? section.layouts.flatMap((layout) =>
|
||||
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
|
||||
)
|
||||
: [],
|
||||
),
|
||||
...board.items.flatMap((item) =>
|
||||
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
|
||||
),
|
||||
];
|
||||
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
|
||||
const removedLayout = removedLayoutsById.get(layoutId);
|
||||
return (
|
||||
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
|
||||
);
|
||||
});
|
||||
if (hasUnresolvableChildLayout) return board;
|
||||
|
||||
const flattened = {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
const isValidFlattenDestination = (
|
||||
board: Board,
|
||||
removedSectionId: string,
|
||||
layoutId: string,
|
||||
destinationSectionId: string,
|
||||
) => {
|
||||
const visited = new Set<string>();
|
||||
let sectionId = destinationSectionId;
|
||||
|
||||
while (true) {
|
||||
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
|
||||
visited.add(sectionId);
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return false;
|
||||
if (section.kind !== "container") return true;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return false;
|
||||
sectionId = layout.parentSectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
|
||||
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
|
||||
|
||||
for (const removedLayout of removed.layouts) {
|
||||
const movedIds = [
|
||||
...original.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.sectionId === removed.id ? [item.id] : [];
|
||||
}),
|
||||
...original.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.parentSectionId === removed.id ? [section.id] : [];
|
||||
}),
|
||||
];
|
||||
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const movedIdSet = new Set(movedIds);
|
||||
const pinned = placements
|
||||
.filter((placement) => movedIdSet.has(placement.id))
|
||||
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
|
||||
if (!pinned) continue;
|
||||
|
||||
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
|
||||
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedByLayoutAndId.size === 0) return board;
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
|
||||
return placement?.type === "item"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
})),
|
||||
sections: board.sections.map((section) =>
|
||||
section.kind !== "container"
|
||||
? section
|
||||
: {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
|
||||
return placement?.type === "section"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { ContainerSectionOptions } from "@homarr/validation/shared";
|
||||
|
||||
import { addContainerCallback } from "./actions/add-container";
|
||||
import type { RemoveContainerInput } from "./actions/remove-container";
|
||||
import { removeContainerCallback } from "./actions/remove-container";
|
||||
|
||||
interface UpdateContainerOptions {
|
||||
containerId: string;
|
||||
newOptions: ContainerSectionOptions;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addContainer = useCallback(() => {
|
||||
updateBoard(addContainerCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateContainer = useCallback(
|
||||
({ containerId, newOptions }: UpdateContainerOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeContainer = useCallback(
|
||||
(input: RemoveContainerInput) => {
|
||||
updateBoard(removeContainerCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return { addContainer, updateContainer, removeContainer };
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof containerSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<Switch
|
||||
label={t("section.option.showLabel.label")}
|
||||
description={t("section.option.showLabel.description")}
|
||||
{...form.getInputProps("showLabel", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.collapsible.label")}
|
||||
description={t("section.option.collapsible.description")}
|
||||
{...form.getInputProps("collapsible", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.showOpenAll.label")}
|
||||
description={t("section.option.showOpenAll.description")}
|
||||
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
|
||||
/>
|
||||
<TextMultiSelect
|
||||
label={t("section.container.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.container.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconLayoutKanban size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowsMove size={16} />}
|
||||
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardContainerSection } from "./container-section";
|
||||
import { FixedGridItem } from "./grid/fixed-grid-item";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, integrations, items } = useSectionContext();
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
|
||||
/** Keep portal content order deterministic across static and edit renderers. */
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
@@ -27,39 +41,57 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<SectionContentItem key={item.id} item={item} integrations={integrations} />
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
return (
|
||||
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
innerRef={innerRef}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
xOffset={item.xOffset}
|
||||
yOffset={item.yOffset}
|
||||
kind={item.kind}
|
||||
id={item.id}
|
||||
type={item.type}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
|
||||
</FixedGridItem>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates a container's minimum width or height from its direct content.
|
||||
* Calculates the min width / height of a section by taking the maximum of
|
||||
* the sum of the offset and size of all items and dynamic sections inside.
|
||||
* @param direction either "x" or "y"
|
||||
* @param items items of the section
|
||||
* @param sections sections of the board to look for dynamic sections
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
*/
|
||||
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
const { items, innerSections } = useSectionItems(item.id);
|
||||
if (item.type === "item") return undefined;
|
||||
|
||||
const size = direction === "x" ? "width" : "height";
|
||||
return Math.max(
|
||||
1,
|
||||
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
|
||||
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
|
||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge, Box, Card } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
interface Props {
|
||||
section: DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const options = section.options;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-15}
|
||||
left={10}
|
||||
size="md"
|
||||
radius={board.itemRadius}
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
bd="1px solid var(--border-color)"
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
} satisfies DynamicSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
|
||||
return {
|
||||
width: 1,
|
||||
height: 1,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveDynamicSectionInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeDynamicSectionCallback =
|
||||
({ id }: RemoveDynamicSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
// Move all items in dynamic section to parent of the removed section
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||
|
||||
interface UpdateDynamicOptions {
|
||||
itemId: string;
|
||||
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard(addDynamicSectionCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateDynamicSection = useCallback(
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user