Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c1a87a03 | ||
|
|
50907c7289 | ||
|
|
9c8fa8158b | ||
|
|
e6e413bcb7 | ||
|
|
81d14e39ab | ||
|
|
f207587791 | ||
|
|
5cc2df19ed | ||
|
|
0a80d70730 | ||
|
|
25c9955cc6 |
No files matched your search
+29
@@ -0,0 +1,29 @@
|
||||
# Product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr makes self-hosted services easy to find, understand, and operate without hand-editing configuration files. A successful board loads immediately, stays visually stable, keeps widgets legible across screen sizes, and makes editing feel direct and forgiving.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Fast, capable, and approachable. Homarr should feel like a mature productivity tool: dense when useful, calm in view mode, and explicit about state during editing.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid cramped tile spacing, undersized text, layout shifts, decorative motion, hidden drag targets, modal-heavy positioning controls, and dashboards that require users to understand grid internals. Editing must not make the read-only experience heavier or slower.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Preserve the board: viewing is the primary workflow and must render before editor code is needed.
|
||||
- Make spatial actions direct: moving, resizing, nesting, and using fixed sidebars should happen on the canvas with immediate feedback.
|
||||
- Keep content predictable: fixed logical tile sizes preserve widget composition, typography, and controls at every viewport.
|
||||
- Recover gracefully: collision handling, reflow, loading states, and persistence failures must never lose or obscure content.
|
||||
- Use familiar controls: Mantine patterns, clear labels, keyboard support, and visible focus states take priority over novel decoration.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Target WCAG 2.2 AA. Maintain keyboard equivalents for drag and resize operations, useful landmarks and announcements, sufficient contrast, readable control sizes, reduced-motion support, and horizontal scrolling before scaling content below an accessible minimum.
|
||||
@@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed).
|
||||
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
|
||||
expose it to the internet.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepAdminUser}
|
||||
alt={"Screenshot form to create admin user"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
|
||||
|
||||
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
|
||||
This group will be used to synchronize users from your provider to Homarr.
|
||||
@@ -116,22 +112,14 @@ The onboarding can no longer be started again.
|
||||
From here you can click on one of the provided links to continue your journey.
|
||||
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepFinish}
|
||||
alt={"Screenshot of the finish onboarding process with links"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
|
||||
|
||||
### Importing a ZIP from version before 1.0.0
|
||||
|
||||
This is recommended when you used Homarr before and want to re-import your data.
|
||||
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
|
||||
|
||||
<img
|
||||
src={manageExportOldHomarr}
|
||||
alt="Export from old Homarr"
|
||||
style={{ maxWidth: 500 }}
|
||||
/>
|
||||
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
|
||||
|
||||
**How to export and import data**
|
||||
|
||||
@@ -149,7 +137,8 @@ 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 can be organized using the interactive drag and drop.
|
||||
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.
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
@@ -197,38 +186,23 @@ Here you have the following options in the header:
|
||||
|
||||
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}
|
||||
/>
|
||||
<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 btuton to save your changes and exit edit mode.
|
||||
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
|
||||
|
||||
To continue, click the option "New Item":
|
||||
|
||||
<img
|
||||
src={manageBoardHeaderAddItem}
|
||||
alt={"Screenshot of the add item menu"}
|
||||
width={200}
|
||||
/>
|
||||
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
|
||||
|
||||
Next, you can choose what item you'd like to add.
|
||||
We will discuss the other options in the following sections - for now choose "App" to continue.
|
||||
|
||||
<img
|
||||
src={manageBoardHeaderChooseItem}
|
||||
alt={"Screenshot of choosing the app item in the modal"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
|
||||
|
||||
As soon as you chose the app, the modal will close and you'll get the following item on your board:
|
||||
|
||||
<img
|
||||
src={manageBoardNoApp}
|
||||
alt="Screenshot of the No app element on the board"
|
||||
/>
|
||||
<img src={manageBoardNoApp} alt="Screenshot of the No app element on the board" />
|
||||
|
||||
This is expected, because you haven't told Homarr what app it should show in this item.
|
||||
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
|
||||
@@ -240,21 +214,28 @@ 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.
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -265,8 +246,10 @@ This helps you to explore the capabilities of Homarr before you build your board
|
||||
|
||||
:::
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||

|
||||
|
||||
@@ -338,43 +321,39 @@ As with every change, don't forget to save your changes using the exit edit mode
|
||||
|
||||

|
||||
|
||||
## Grouping your items
|
||||
## Organizing items with sections
|
||||
|
||||
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.
|
||||
Sections are resizable containers for apps, widgets, and other sections.
|
||||
The same section works on the main canvas and inside a fixed sidebar.
|
||||
Each section can configure:
|
||||
|
||||
### Using dynamic sections
|
||||
|
||||
Dynamic sections are a simple, resizable visual group to group items.
|
||||
- Show or hide its label.
|
||||
- Allow or disable collapsing.
|
||||
- Show or hide **Open all in new tabs**.
|
||||
- Change its border color and custom CSS classes.
|
||||
|
||||

|
||||
|
||||
After adding a dynamic section, you can simply drag and drop items into the section:
|
||||
After adding a section, drag and resize it like any other tile, then drag items into it:
|
||||
|
||||

|
||||
|
||||
### Using categories
|
||||
### Labeled and collapsible sections
|
||||
|
||||
Categories are more advanced and complicated than dynamic sections.
|
||||
To create a category, open the add item modal and choose "new category":
|
||||
Open the add-content menu and choose **New section**.
|
||||
Use its settings menu to add a label and enable the behavior you want.
|
||||
In edit mode, move items into or out of the section directly.
|
||||
In view mode, each user can collapse eligible sections without changing the saved board layout.
|
||||
If **Open all in new tabs** is enabled, the section menu can open every app link it contains.
|
||||
|
||||

|
||||
### Fixed sidebars
|
||||
|
||||
Every category must have a name. After you click "Add category", you will get a new item on your board:
|
||||
Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout.
|
||||
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
|
||||
The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
|
||||
Sidebar content stays visible while the page scrolls and remains editable.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
:::
|
||||
Existing categories become sections automatically, and category rails become fixed sidebars.
|
||||
|
||||
## Guided tours
|
||||
|
||||
|
||||
@@ -83,3 +83,10 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
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.
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,24 +24,30 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
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.
|
||||
|
||||
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, with horizontal scrolling when further shrinking would make controls too small.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -51,7 +57,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||

|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -59,6 +67,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -67,78 +76,156 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
### Layout
|
||||
|
||||
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
On each element the position and size of items can be changed seperately.
|
||||
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 scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size.
|
||||
|
||||
The position and size of every item can be changed separately in each responsive layout.
|
||||
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.
|
||||
New items use their widget's default grid size, while a new inline section starts at a useful `4 × 3` size when the layout has enough columns.
|
||||
Both are placed in the first available position.
|
||||
|
||||
Moving content downward in edit mode extends the canvas automatically, while view mode trims it back to the content.
|
||||
Nested sections keep their own resizable bounds.
|
||||
|
||||
#### Name
|
||||
|
||||
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
|
||||
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
|
||||
|
||||
#### Column count
|
||||
|
||||
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.
|
||||
Total number of columns for this layout.
|
||||
When changed, Homarr moves items into the next available positions without overlap.
|
||||
|
||||
#### Fixed sidebars
|
||||
|
||||
Each responsive layout can enable a fixed left sidebar, a fixed right sidebar, or both.
|
||||
Set a sidebar 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.
|
||||
|
||||
Sidebar columns are reserved from the layout's total column count.
|
||||
For example, a 12-column layout with a two-column left sidebar leaves ten columns for the canvas.
|
||||
Enabling or widening a sidebar reflows canvas items into the remaining columns.
|
||||
Disabling a sidebar moves its contents back into the next available canvas positions.
|
||||
|
||||
Sidebars remain visible while the page scrolls.
|
||||
Apps, widgets, and sections inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode.
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
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; the visible grip and tile outline indicate the active move surface, while the tile's settings button remains available.
|
||||
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.
|
||||
|
||||
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.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric or touch input.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Sections and sidebars
|
||||
|
||||
Sections can contain apps, widgets, and nested sections.
|
||||
Every section uses the same model whether it is on the canvas or inside a sidebar.
|
||||
Sections can be moved and resized directly in edit mode.
|
||||
Expanding it creates space for its contents and pushes following items out of the way; collapsing it compacts the board.
|
||||
|
||||
Each section can configure:
|
||||
|
||||
- Whether its label is visible.
|
||||
- Whether users may collapse it.
|
||||
- Whether its menu shows **Open all in new tabs**.
|
||||
- Its label, border color, and custom CSS classes.
|
||||
|
||||
Existing categories and dynamic sections are converted into this unified section model.
|
||||
Existing category rails become board sidebars automatically.
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
@@ -147,14 +234,16 @@ The styling will only be applied on the respective board page.
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -173,18 +262,23 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
@@ -196,7 +290,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -206,47 +300,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
|
||||
All the following paths will lead to the home board:
|
||||
- ``my-homarr.com``
|
||||
- ``my-homarr.com/boards``
|
||||
|
||||
- `my-homarr.com`
|
||||
- `my-homarr.com/boards`
|
||||
|
||||
Your home board will have a special small badge at the top right in the list of boards:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can also define it via your user profile:
|
||||
|
||||
1. Click on your user profile at the top right
|
||||
2. Click on ``Your preferences``
|
||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
||||
2. Click on `Your preferences`
|
||||
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||
|
||||
### For other users
|
||||
|
||||
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
|
||||
1. Navigate to the users: `Management` > `Users` > `Manage`
|
||||
2. Click on the username of the user that you want to adjust
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For user groups
|
||||
|
||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
||||
2. Go to tab ``Settings``
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||
2. Go to tab `Settings`
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For whole server
|
||||
|
||||
You can also set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
|
||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -255,4 +353,4 @@ For the global home board you can only select boards that are public.
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
@@ -68,6 +68,10 @@ 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
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@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",
|
||||
@@ -81,6 +80,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"gridstack": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
const getHomeBoardCachedAsync = cache(async () => await api.board.getHomeBoard());
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardCachedAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
const getBoardByNameCachedAsync = cache(async (name: string) => await api.board.getBoardByName({ name }));
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameCachedAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
transform: translateY(var(--board-gutter-sticky-offset, 0px));
|
||||
}
|
||||
@@ -1,33 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridPortalHost } 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;
|
||||
|
||||
const useFixedBoardGutters = () => {
|
||||
const columnsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const columns = columnsRef.current;
|
||||
if (!columns) return;
|
||||
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const columnsRect = columns.getBoundingClientRect();
|
||||
const headerBottom = document.querySelector("header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const stickyTop = Math.max(0, headerBottom + 16);
|
||||
|
||||
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
|
||||
const rect = gutter.getBoundingClientRect();
|
||||
if (gutter.offsetWidth <= 0) continue;
|
||||
|
||||
const effectiveScale = rect.width / gutter.offsetWidth;
|
||||
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
|
||||
|
||||
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
|
||||
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
|
||||
const maximumTop = columnsRect.bottom - rect.height;
|
||||
const targetTop = Math.min(stickyTop, maximumTop);
|
||||
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
|
||||
|
||||
gutter.dataset.stickyOffset = String(nextOffset);
|
||||
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== 0) return;
|
||||
frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
||||
resizeObserver.observe(columns);
|
||||
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return columnsRef;
|
||||
};
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const columnsRef = useFixedBoardGutters();
|
||||
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 fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
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);
|
||||
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(" ");
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<div ref={columnsRef} className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,15 +2,11 @@ import type { Metadata } from "next";
|
||||
import { Suspense } 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 type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
@@ -25,8 +21,9 @@ import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
import { BoardIntegrationProvider } from "./_integration-provider";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
|
||||
@@ -85,9 +82,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
<BoardIntegrationProvider initialIntegrations={integrations}>
|
||||
<ClientBoard />
|
||||
</BoardIntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-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 = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal();
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Box, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
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 { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
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/dynamic/dynamic-menu"),
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
@@ -52,13 +53,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -69,96 +70,10 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -185,20 +100,73 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
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 () => {
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
setIsEnteringEditMode(true);
|
||||
try {
|
||||
await prepareEditorAsync();
|
||||
startTransition(open);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.loadError.title"),
|
||||
message: t("notification.loadError.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnteringEditMode(false);
|
||||
}
|
||||
}, [board, demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
onFocus={prewarmEditor}
|
||||
onPointerEnter={prewarmEditor}
|
||||
loading={isPending || isEnteringEditMode}
|
||||
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-pressed={isEditMode}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -207,13 +175,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
@@ -294,5 +263,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
"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>;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
.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,6 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Fieldset,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -9,6 +23,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import classes from "./_layout.module.css";
|
||||
|
||||
const layoutSettingsSchema = boardSaveLayoutsSchema.omit({ id: true });
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
@@ -22,7 +39,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
const form = useZodForm(layoutSettingsSchema, {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
@@ -33,7 +50,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
layouts: values.layouts,
|
||||
});
|
||||
})}
|
||||
>
|
||||
@@ -51,6 +68,8 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
@@ -70,7 +89,21 @@ export const LayoutSettingsContent = ({ board }: 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} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -81,6 +114,15 @@ export const LayoutSettingsContent = ({ board }: 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">
|
||||
@@ -113,3 +155,123 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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,4 +1,5 @@
|
||||
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";
|
||||
@@ -6,6 +7,7 @@ import { TRPCError } from "@trpc/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 { MainHeader } from "~/components/layout/header";
|
||||
@@ -62,9 +64,15 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
});
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const initialViewportWidth = await getInitialViewportWidthAsync();
|
||||
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
@@ -88,3 +96,22 @@ 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;
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-transla
|
||||
|
||||
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";
|
||||
@@ -144,6 +145,7 @@ 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 />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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";
|
||||
|
||||
@@ -19,17 +21,21 @@ export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.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: createItemLayouts(previous, firstSection, kind),
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -60,9 +66,14 @@ 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, boardLayout.columnCount, 9999, itemSize)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
@@ -15,16 +17,17 @@ 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: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
layouts: clonedLayouts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -67,18 +72,24 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
const columnCount =
|
||||
section.kind === "empty"
|
||||
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
|
||||
: boardLayout.columnCount;
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.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; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
@@ -10,6 +10,10 @@ 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";
|
||||
@@ -52,7 +56,7 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -94,8 +98,10 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -104,8 +110,35 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
9999,
|
||||
undefined,
|
||||
{ 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 { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,6 +7,10 @@ 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";
|
||||
@@ -61,4 +65,28 @@ 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-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 DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
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",
|
||||
dynamicSection: "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 DynamicSectionMockBuilder({ 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",
|
||||
dynamicSection: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,21 +1,23 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
|
||||
options?: Partial<DynamicSection["options"]>;
|
||||
};
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: Partial<DynamicSection>) {
|
||||
constructor(section?: DynamicSectionMock) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
collapsed: section?.collapsed ?? false,
|
||||
options: dynamicSectionOptionsSchema.parse(section?.options),
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
|
||||
@@ -10,3 +10,14 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editModeInertContent * {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -17,10 +18,13 @@ 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 { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
@@ -41,6 +45,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
@@ -118,8 +125,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -131,23 +144,29 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef } 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 { useIntegrations, useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -14,7 +13,7 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
@@ -34,15 +33,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const integrationData = useIntegrations();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const menuRightOffset = section.kind === "dynamic" ? offset + 40 : offset;
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -52,7 +53,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal(
|
||||
@@ -78,7 +79,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -89,8 +90,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -114,9 +115,9 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
@@ -129,8 +130,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
|
||||
@@ -1,94 +1,209 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, 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 type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount, getLayoutRowCount, reflowVerticalLayout } from "../layout";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | DynamicSectionItem,
|
||||
"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;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
dynamicSection: 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 t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
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: t("board.landmark.canvas"),
|
||||
dynamicSection: t("section.dynamic.action.create"),
|
||||
leftRail: t("board.landmark.leftRail"),
|
||||
rightRail: t("board.landmark.rightRail"),
|
||||
numbered: (name, index) => t("item.moveResize.target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
t("item.moveResize.target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, t],
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
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),
|
||||
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),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
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;
|
||||
}
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(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,
|
||||
(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),
|
||||
y: values.yOffset,
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = reflowVerticalLayout([...targetPlacements, nextPlacement], {
|
||||
columnCount: target.columnCount,
|
||||
pinnedItemId: entry.id,
|
||||
});
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", t("item.moveResize.keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: reflowVerticalLayout(
|
||||
getSectionPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
{
|
||||
columnCount: getSectionColumnCount(board, currentLayoutId, sourceSectionId),
|
||||
},
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
[actions, board, commitSectionGrids, currentLayoutId, entry, form, minimumSize, sourceSectionId, t, targetById],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={t("item.moveResize.field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -101,3 +216,177 @@ 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),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (section.kind === "category") return [];
|
||||
|
||||
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.dynamicSection,
|
||||
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 getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
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`);
|
||||
|
||||
if (section.kind === "dynamic") {
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!sectionLayout) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return sectionLayout.width;
|
||||
}
|
||||
if (section.kind === "category") return layout.columnCount;
|
||||
return getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "dynamic") {
|
||||
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 = getSectionPlacements(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 === "dynamic");
|
||||
if (!section || section.kind !== "dynamic") 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);
|
||||
};
|
||||
@@ -21,11 +21,11 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: integrationData = [] } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
const availableKinds = useMemo(() => new Set(integrationData.map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
@@ -79,7 +79,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
? integrationData.filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useIntegrations, useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
@@ -32,7 +32,7 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
@@ -50,14 +50,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const integrationData = useIntegrations();
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { section } = useSectionContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
@@ -131,7 +131,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
}
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -169,7 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
if (!isRightClickEnabled || isEditMode) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
@@ -221,8 +221,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
@@ -254,12 +256,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Menu.Item closeMenuOnClick leftSection={<IconSettings size={16} />} onClick={openEditModal}>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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 transformed 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 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`.
|
||||
@@ -0,0 +1,94 @@
|
||||
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 !== "dynamic") 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 === "dynamic" &&
|
||||
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));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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 MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 1;
|
||||
@@ -0,0 +1,54 @@
|
||||
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 = toNonNegativeInteger(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 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;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants";
|
||||
export * from "./board-lanes";
|
||||
export * from "./geometry";
|
||||
export * from "./reflow";
|
||||
export * from "./semantics";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,140 @@
|
||||
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 (collapsedRowCount < 1 || !Number.isInteger(collapsedRowCount)) {
|
||||
throw new RangeError("collapsedRowCount must be a positive integer");
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) =>
|
||||
collapsedItemIds.has(placement.id)
|
||||
? {
|
||||
...placement,
|
||||
h: collapsedRowCount,
|
||||
}
|
||||
: placement,
|
||||
),
|
||||
{ columnCount },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, 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 scale floor deliberately allows horizontal
|
||||
* overflow so browser zoom still magnifies content instead of being cancelled
|
||||
* by responsive downscaling.
|
||||
*/
|
||||
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 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-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1,112 @@
|
||||
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 { 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 { 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 dynamicSectionId = "dynamic";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new DynamicSectionMockBuilder({
|
||||
id: dynamicSectionId,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { 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(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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
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 and pulls neighbors up", () => {
|
||||
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: 1 },
|
||||
{ id: "below", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 0, w: 1, 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]));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
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("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified 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).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface GridCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridPlacement extends GridCoordinates {
|
||||
id: string;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
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);
|
||||
@@ -1,79 +0,0 @@
|
||||
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
@@ -1,145 +0,0 @@
|
||||
/* 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(),
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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 = 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,
|
||||
};
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { FixedGridItem } from "./grid/fixed-grid-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
const { innerSections, integrations, items } = useSectionContext();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
@@ -35,7 +31,7 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
<SectionContentItem key={item.id} item={item} integrations={integrations} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -43,30 +39,17 @@ export const SectionContent = () => {
|
||||
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
</FixedGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,13 +62,14 @@ const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
*/
|
||||
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
export 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(
|
||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
1,
|
||||
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
|
||||
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,52 @@
|
||||
import { Badge, Box, Card } from "@mantine/core";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
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 BoardDynamicSectionMenu = dynamic(
|
||||
() => import("./dynamic/dynamic-menu").then((module) => module.BoardDynamicSectionMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.dynamic");
|
||||
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 || t("action.create");
|
||||
const contentId = `board-section-${section.id}-content`;
|
||||
const labelLeft = !isEditMode && options.collapsible ? 40 : 10;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
data-grid-item-content
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
@@ -38,24 +65,65 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.title && (
|
||||
{options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-15}
|
||||
left={10}
|
||||
top={6}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
radius={board.itemRadius}
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
bd="1px solid var(--border-color)"
|
||||
style={{ zIndex: 9, pointerEvents: "none" }}
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
{options.collapsible && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10 }}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${isVisuallyCollapsed ? t("action.expand") : t("action.collapse")}: ${label}`}
|
||||
>
|
||||
{isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
loading={areAppsLoading}
|
||||
onClick={openAllInNewTabs}
|
||||
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Box id={contentId} h="100%" hidden={isVisuallyCollapsed}>
|
||||
{!isVisuallyCollapsed && (
|
||||
<SectionGrid
|
||||
section={section}
|
||||
columnCount={section.width}
|
||||
requestedRowCount={section.height}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
{isEditMode && <BoardDynamicSectionMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,30 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, 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 addDynamicSectionCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.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 = createDynamicSectionLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
collapsed: false,
|
||||
options: dynamicSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies DynamicSection;
|
||||
|
||||
return {
|
||||
@@ -36,9 +39,14 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
|
||||
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, boardLayout.columnCount)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -46,8 +54,7 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
|
||||
}
|
||||
|
||||
return {
|
||||
width: 1,
|
||||
height: 1,
|
||||
...size,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -24,7 +24,9 @@ export const useDynamicSectionActions = () => {
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== itemId || item.kind !== "dynamic" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
@@ -18,7 +18,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema, {
|
||||
const form = useZodForm(dynamicSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
@@ -32,6 +32,21 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.dynamic.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.dynamic.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
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 { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
|
||||
@@ -14,9 +16,13 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tDynamic("action.create");
|
||||
const menuRightOffset = parentSection.kind === "dynamic" ? 44 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
@@ -50,11 +56,11 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
<IconLayoutKanban size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
@@ -62,6 +68,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
<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}>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
interface Props {
|
||||
section: EmptySection;
|
||||
columnCount: number;
|
||||
requestedRowCount: number;
|
||||
railPlacement?: "main" | "left" | "right";
|
||||
}
|
||||
|
||||
export const BoardEmptySection = ({ section }: Props) => {
|
||||
export const BoardEmptySection = ({ section, columnCount, requestedRowCount, railPlacement = "main" }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const totalLength = items.length + innerSections.length;
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
|
||||
|
||||
return (
|
||||
<GridStack
|
||||
<SectionGrid
|
||||
section={section}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
columnCount={columnCount}
|
||||
requestedRowCount={requestedRowCount}
|
||||
label={t("items")}
|
||||
railPlacement={railPlacement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getEditableGridCellAttributes,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getReadonlyGridItemAttributes,
|
||||
placeGridItem,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import classes from "./section-grid.module.css";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
interface FixedGridItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
export const FixedGridItem = ({
|
||||
item,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
children,
|
||||
}: PropsWithChildren<FixedGridItemProps>) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const instructionsId = useId();
|
||||
const placement = toPlacement(item);
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
|
||||
: item.options.title.trim() || t("section.dynamic.untitled");
|
||||
const accessibleLabel = t("item.moveResize.entryLabel", {
|
||||
name: displayName,
|
||||
column: String(placement.x + 1),
|
||||
row: String(placement.y + 1),
|
||||
});
|
||||
|
||||
const setKeyboardEditing = (next: boolean) => {
|
||||
setIsKeyboardEditing(next);
|
||||
announce(
|
||||
`${accessibleLabel}: ${t(next ? "item.moveResize.keyboard.started" : "item.moveResize.keyboard.stopped")}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || !isEditMode || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(!isKeyboardEditing);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && isKeyboardEditing) {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const allPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const current = allPlacements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
const next = getKeyboardPlacement(current, event.key, event.shiftKey, {
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
});
|
||||
if (next.x === current.x && next.y === current.y && next.w === current.w && next.h === current.h) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = placeGridItem(allPlacements, next, columnCount) as SectionGridPlacement[];
|
||||
if (maxRowCount !== null && getLayoutRowCount(resolved) > maxRowCount) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: resolved });
|
||||
announce(
|
||||
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
|
||||
"item.moveResize.field.yOffset.label",
|
||||
)} ${next.y + 1}, ${t("item.moveResize.field.width.label")} ${next.w}, ${t(
|
||||
"item.moveResize.field.height.label",
|
||||
)} ${next.h}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (isKeyboardEditing && !event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsKeyboardEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const semantics = isEditMode
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
className={combineClasses(isEditMode ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditMode ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditMode ? undefined : item.id}
|
||||
data-grid-item-type={isEditMode ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditMode ? "true" : undefined}
|
||||
data-type={item.type}
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditMode ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditMode ? instructionsId : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditMode
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
: undefined
|
||||
}
|
||||
onBlur={isEditMode ? handleBlur : undefined}
|
||||
onKeyDown={isEditMode ? handleKeyboard : undefined}
|
||||
>
|
||||
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
|
||||
{children}
|
||||
{isEditMode && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
});
|
||||
|
||||
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
|
||||
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
|
||||
|
||||
const getKeyboardPlacement = (
|
||||
current: SectionGridPlacement,
|
||||
key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown",
|
||||
resize: boolean,
|
||||
{
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
}: {
|
||||
columnCount: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxRowCount: number | null;
|
||||
},
|
||||
): GridPlacement & Pick<SectionGridPlacement, "type"> => {
|
||||
if (resize) {
|
||||
const widthDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const heightDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
w: Math.max(minWidth, Math.min(columnCount - current.x, current.w + widthDelta)),
|
||||
h: Math.max(
|
||||
minHeight,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.y, current.h + heightDelta),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const xDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const yDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
x: Math.max(0, Math.min(columnCount - current.w, current.x + xDelta)),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.h, current.y + yDelta),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
/**
|
||||
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||
* two slots and leave every other item where it started.
|
||||
*/
|
||||
export const getDropSwapPlacements = (
|
||||
initial: readonly SectionGridPlacement[],
|
||||
current: readonly SectionGridPlacement[],
|
||||
movedItemId: string,
|
||||
bounds: { columnCount: number; rowCount?: number },
|
||||
): SectionGridPlacement[] | null => {
|
||||
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||
if (!movedInitially || !movedCurrently) return null;
|
||||
if (
|
||||
movedInitially.x === movedCurrently.x &&
|
||||
movedInitially.y === movedCurrently.y &&
|
||||
movedInitially.w === movedCurrently.w &&
|
||||
movedInitially.h === movedCurrently.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaced = initial.find(
|
||||
(placement) => placement.id !== movedItemId && placement.x === movedCurrently.x && placement.y === movedCurrently.y,
|
||||
);
|
||||
if (!displaced) return null;
|
||||
|
||||
const swapped = initial.map((placement) => {
|
||||
if (placement.id === movedItemId) {
|
||||
return {
|
||||
...placement,
|
||||
x: displaced.x,
|
||||
y: displaced.y,
|
||||
};
|
||||
}
|
||||
if (placement.id === displaced.id) {
|
||||
return {
|
||||
...placement,
|
||||
x: movedInitially.x,
|
||||
y: movedInitially.y,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
|
||||
return placementsFit(swapped, bounds) ? swapped : null;
|
||||
};
|
||||
|
||||
const placementsFit = (
|
||||
placements: readonly SectionGridPlacement[],
|
||||
{ columnCount, rowCount }: { columnCount: number; rowCount?: number },
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= columnCount &&
|
||||
(rowCount === undefined || placement.y + placement.h <= rowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type * as GridEditorModule from "./grid-editor";
|
||||
|
||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||
|
||||
export const loadGridEditorAsync = () => {
|
||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return editorModulePromise;
|
||||
};
|
||||
|
||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||
let animationFrameId: number | undefined;
|
||||
let idleCallbackId: number | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let hydrationObserver: MutationObserver | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const load = () => {
|
||||
if (!cancelled) void loadAsync().catch(() => undefined);
|
||||
};
|
||||
const scheduleAfterPaint = () => {
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleCallbackId = window.requestIdleCallback(load, { timeout: 2_000 });
|
||||
} else {
|
||||
timeoutId = setTimeout(load, 1_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
const scheduleWhenInteractive = () => {
|
||||
if (document.querySelector('[data-board-hydrated="true"]')) {
|
||||
scheduleAfterPaint();
|
||||
return;
|
||||
}
|
||||
|
||||
hydrationObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('[data-board-hydrated="true"]')) return;
|
||||
hydrationObserver?.disconnect();
|
||||
hydrationObserver = undefined;
|
||||
scheduleAfterPaint();
|
||||
});
|
||||
hydrationObserver.observe(document.documentElement, {
|
||||
attributeFilter: ["data-board-hydrated"],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
scheduleWhenInteractive();
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
scheduleWhenInteractive();
|
||||
} else {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
hydrationObserver?.disconnect();
|
||||
if (animationFrameId !== undefined) window.cancelAnimationFrame(animationFrameId);
|
||||
if (idleCallbackId !== undefined && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
.grid-stack.grid-stack-editing > .grid-stack-placeholder > .placeholder-content {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging {
|
||||
filter: drop-shadow(var(--mantine-shadow-lg));
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
transition:
|
||||
outline-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
|
||||
outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background-image: none;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transform: none;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
content: "";
|
||||
border-bottom: 2px solid currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
|
||||
right: 8px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-radius: 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
left: 8px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-radius: 0 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
right: 25%;
|
||||
bottom: 5px;
|
||||
left: 25%;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-stack.grid-stack-editing,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing .ui-resizable-handle::after {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
import "gridstack/dist/gridstack.min.css"; // oxlint-disable-line import/no-unassigned-import
|
||||
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
|
||||
|
||||
import type { AddRemoveFcn, GridItemHTMLElement, GridStackNode, GridStackWidget } from "gridstack";
|
||||
import { GridStack } from "gridstack";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { getDropSwapPlacements } from "./grid-drop-swap";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
const AUTO_EXPAND_ROW_BUFFER = 8;
|
||||
|
||||
interface GridEditorProps {
|
||||
sectionId: string;
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
fixedRowCount: boolean;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only board module that imports GridStack. The library owns every
|
||||
* positioned shell, while one board-level portal host owns React content.
|
||||
* Moving a shell between independent grids therefore never reparents a
|
||||
* React-owned element.
|
||||
*/
|
||||
export default function GridEditor({
|
||||
sectionId,
|
||||
columnCount,
|
||||
rowCount,
|
||||
fixedRowCount,
|
||||
placements,
|
||||
className,
|
||||
}: GridEditorProps) {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { announce, innerSections, items } = useSectionContext();
|
||||
const { acquireContainer, releaseContainer } = useBoardGridPortalHost();
|
||||
const { commitSectionGrid, commitSectionGrids } = useGridLayoutActions();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<GridStack | null>(null);
|
||||
const applyingControlledLayoutRef = useRef(false);
|
||||
const placementsRef = useRef(placements);
|
||||
const dragSnapshotRef = useRef<{
|
||||
grid: GridStack;
|
||||
itemId: string;
|
||||
placements: SectionGridPlacement[];
|
||||
} | null>(null);
|
||||
const entriesRef = useRef([...items, ...innerSections]);
|
||||
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
|
||||
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
|
||||
|
||||
placementsRef.current = placements;
|
||||
entriesRef.current = [...items, ...innerSections];
|
||||
dimensionsRef.current = { columnCount, fixedRowCount, rowCount };
|
||||
|
||||
const forbiddenDynamicSectionIds = useMemo(
|
||||
() => getTargetAncestorSectionIds(board.sections, currentLayoutId, sectionId),
|
||||
[board.sections, currentLayoutId, sectionId],
|
||||
);
|
||||
acceptWidgetRef.current = (element) => {
|
||||
const node = (element as GridItemHTMLElement).gridstackNode;
|
||||
const id = element.getAttribute("data-grid-item-id") ?? node?.id;
|
||||
if (!id) return false;
|
||||
if ((node?.minW ?? 1) > columnCount) return false;
|
||||
if (fixedRowCount && (node?.minH ?? 1) > rowCount) return false;
|
||||
|
||||
return element.getAttribute("data-grid-item-type") !== "section" || !forbiddenDynamicSectionIds.has(String(id));
|
||||
};
|
||||
|
||||
const shellCallback = useCallback<AddRemoveFcn>(
|
||||
(_parent, widget, add, isGrid) => {
|
||||
if (isGrid) return undefined;
|
||||
const id = widget.id ? String(widget.id) : null;
|
||||
if (!id) return undefined;
|
||||
|
||||
if (!add) {
|
||||
const element = (widget as GridStackNode).el;
|
||||
const container = element?.querySelector<HTMLElement>(":scope > .grid-stack-item-content");
|
||||
if (container) releaseContainer(id, container);
|
||||
element?.remove();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const element = document.createElement("div") as GridItemHTMLElement;
|
||||
element.className = "grid-stack-item";
|
||||
element.appendChild(acquireContainer(id));
|
||||
return element;
|
||||
},
|
||||
[acquireContainer, releaseContainer],
|
||||
);
|
||||
|
||||
const snapshotGrid = useCallback(
|
||||
(grid: GridStack | undefined) => {
|
||||
if (!grid?.el) return null;
|
||||
const snapshotSectionId = grid.el.getAttribute("data-grid-section-id");
|
||||
if (!snapshotSectionId) return null;
|
||||
|
||||
return {
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: snapshotSectionId,
|
||||
placements: getGridPlacements(grid),
|
||||
};
|
||||
},
|
||||
[currentLayoutId],
|
||||
);
|
||||
|
||||
const commitGrid = useCallback(
|
||||
(grid: GridStack | undefined) => {
|
||||
if (applyingControlledLayoutRef.current) return;
|
||||
const snapshot = snapshotGrid(grid);
|
||||
if (snapshot) commitSectionGrid(snapshot);
|
||||
},
|
||||
[commitSectionGrid, snapshotGrid],
|
||||
);
|
||||
|
||||
const announceNode = useCallback(
|
||||
(node: GridStackNode | undefined) => {
|
||||
if (!node?.id) return;
|
||||
const entry = entriesRef.current.find((candidate) => candidate.id === String(node.id));
|
||||
const displayName =
|
||||
entry?.type === "item"
|
||||
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
|
||||
: entry?.type === "section"
|
||||
? entry.options.title.trim() || t("section.dynamic.untitled")
|
||||
: t("item.action.moveResize");
|
||||
const label = t("item.moveResize.entryLabel", {
|
||||
name: displayName,
|
||||
column: String((node.x ?? 0) + 1),
|
||||
row: String((node.y ?? 0) + 1),
|
||||
});
|
||||
announce(
|
||||
`${label}: ${t("item.moveResize.field.width.label")} ${node.w ?? 1}, ${t(
|
||||
"item.moveResize.field.height.label",
|
||||
)} ${node.h ?? 1}`,
|
||||
);
|
||||
},
|
||||
[announce, t],
|
||||
);
|
||||
const eventHandlersRef = useRef({
|
||||
announceNode,
|
||||
commitGrid,
|
||||
commitSectionGrids,
|
||||
snapshotGrid,
|
||||
});
|
||||
eventHandlersRef.current = {
|
||||
announceNode,
|
||||
commitGrid,
|
||||
commitSectionGrids,
|
||||
snapshotGrid,
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const dimensions = dimensionsRef.current;
|
||||
|
||||
const grid = GridStack.init(
|
||||
{
|
||||
acceptWidgets: (element) => acceptWidgetRef.current(element),
|
||||
alwaysShowResizeHandle: true,
|
||||
animate: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
cellHeight: LOGICAL_GRID_PITCH,
|
||||
column: dimensions.columnCount,
|
||||
draggable: {
|
||||
cancel:
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.ui-resizable-handle',
|
||||
handle: "[data-editor-grid-entry]",
|
||||
scroll: true,
|
||||
},
|
||||
float: true,
|
||||
margin: `0 ${LOGICAL_GRID_GAP}px ${LOGICAL_GRID_GAP}px 0`,
|
||||
maxRow: dimensions.fixedRowCount ? dimensions.rowCount : 0,
|
||||
minRow: dimensions.fixedRowCount ? dimensions.rowCount : 1,
|
||||
resizable: {
|
||||
autoHide: false,
|
||||
handles: "e, se, s, sw, w",
|
||||
},
|
||||
},
|
||||
wrapper,
|
||||
);
|
||||
if (!grid) return;
|
||||
|
||||
// GridStack infers a native sub-grid from DOM nesting. Homarr owns nested
|
||||
// grids independently so content can move between them without remounting;
|
||||
// keeping the inferred link makes parent/child teardown order unsafe.
|
||||
const inferredParentNode = grid.parentGridNode;
|
||||
if (inferredParentNode) {
|
||||
if (inferredParentNode.subGrid === grid) delete inferredParentNode.subGrid;
|
||||
delete grid.parentGridNode;
|
||||
wrapper.classList.remove("grid-stack-nested");
|
||||
inferredParentNode.el?.classList.remove("grid-stack-sub-grid");
|
||||
}
|
||||
|
||||
gridRef.current = grid;
|
||||
wrapper.setAttribute("data-grid-section-id", sectionId);
|
||||
wrapper.classList.add("grid-stack-editing");
|
||||
|
||||
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const dragSnapshot = dragSnapshotRef.current;
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (dragSnapshot?.grid === changedGrid) {
|
||||
const swapped = getDropSwapPlacements(
|
||||
dragSnapshot.placements,
|
||||
getGridPlacements(changedGrid),
|
||||
dragSnapshot.itemId,
|
||||
{
|
||||
columnCount: dimensionsRef.current.columnCount,
|
||||
rowCount: dimensionsRef.current.fixedRowCount ? dimensionsRef.current.rowCount : undefined,
|
||||
},
|
||||
);
|
||||
if (swapped) {
|
||||
applyingControlledLayoutRef.current = true;
|
||||
try {
|
||||
changedGrid.load(swapped.map(toGridStackWidget), shellCallback);
|
||||
} finally {
|
||||
applyingControlledLayoutRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventHandlersRef.current.commitGrid(changedGrid);
|
||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||
decorateGrid(changedGrid, placementsRef.current);
|
||||
restoreContentHeight(grid);
|
||||
});
|
||||
};
|
||||
const handleDragStart = (_event: Event, element: GridItemHTMLElement) => {
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const itemId = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
|
||||
if (itemId) {
|
||||
dragSnapshotRef.current = {
|
||||
grid: changedGrid,
|
||||
itemId: String(itemId),
|
||||
placements: getGridPlacements(changedGrid),
|
||||
};
|
||||
}
|
||||
|
||||
const currentDimensions = dimensionsRef.current;
|
||||
if (currentDimensions.fixedRowCount) return;
|
||||
|
||||
grid.updateOptions({
|
||||
minRow: currentDimensions.rowCount + AUTO_EXPAND_ROW_BUFFER,
|
||||
});
|
||||
setViewportRowCount(grid, currentDimensions.rowCount + AUTO_EXPAND_ROW_BUFFER);
|
||||
};
|
||||
const handleDropped = (
|
||||
_event: Event,
|
||||
previousNode: GridStackNode | undefined,
|
||||
newNode: GridStackNode | undefined,
|
||||
) => {
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (applyingControlledLayoutRef.current) return;
|
||||
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
|
||||
.map((changedGrid) => eventHandlersRef.current.snapshotGrid(changedGrid))
|
||||
.filter((snapshot) => snapshot !== null);
|
||||
if (snapshots.length > 0) eventHandlersRef.current.commitSectionGrids(snapshots);
|
||||
eventHandlersRef.current.announceNode(newNode);
|
||||
for (const changedGrid of uniqueGrids([previousNode?.grid, newNode?.grid])) {
|
||||
decorateGrid(changedGrid, placementsRef.current);
|
||||
restoreContentHeight(changedGrid);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
grid.on("dragstart", handleDragStart);
|
||||
grid.on("dragstop", handleStop);
|
||||
grid.on("resizestop", handleStop);
|
||||
grid.on("dropped", handleDropped);
|
||||
|
||||
return () => {
|
||||
grid.offAll();
|
||||
const elements = grid.getGridItems();
|
||||
for (const element of elements) {
|
||||
const id = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
|
||||
const container = element.querySelector<HTMLElement>(":scope > .grid-stack-item-content");
|
||||
if (id && container) releaseContainer(String(id), container);
|
||||
}
|
||||
grid.destroy(false);
|
||||
for (const element of elements) element.remove();
|
||||
if (gridRef.current === grid) gridRef.current = null;
|
||||
};
|
||||
}, [releaseContainer, sectionId, shellCallback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
grid.updateOptions({
|
||||
column: columnCount,
|
||||
maxRow: fixedRowCount ? rowCount : 0,
|
||||
minRow: fixedRowCount ? rowCount : 1,
|
||||
});
|
||||
}, [columnCount, fixedRowCount, rowCount]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
|
||||
applyingControlledLayoutRef.current = true;
|
||||
grid.load(placements.map(toGridStackWidget), shellCallback);
|
||||
applyingControlledLayoutRef.current = false;
|
||||
decorateGrid(grid, placements);
|
||||
restoreContentHeight(grid);
|
||||
|
||||
const actual = getGridPlacements(grid);
|
||||
if (!arePlacementsEqual(actual, placements)) {
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId, placements: actual });
|
||||
}
|
||||
}, [commitSectionGrid, currentLayoutId, placements, sectionId, shellCallback]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={className}
|
||||
data-grid-section-id={sectionId}
|
||||
style={
|
||||
{
|
||||
width: columnCount * LOGICAL_GRID_PITCH,
|
||||
minHeight: rowCount * LOGICAL_GRID_PITCH,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<span
|
||||
data-grid-runtime="homarr-edit-grid-v1"
|
||||
data-editor-section-id={sectionId}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", width: 1, height: 1, pointerEvents: "none" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const toGridStackWidget = (placement: SectionGridPlacement): GridStackWidget => ({
|
||||
id: placement.id,
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
w: placement.w,
|
||||
h: placement.h,
|
||||
minW: placement.minW ?? 1,
|
||||
minH: placement.minH ?? 1,
|
||||
});
|
||||
|
||||
const getGridPlacements = (grid: GridStack): SectionGridPlacement[] =>
|
||||
grid.getGridItems().flatMap((element) => {
|
||||
const node = element.gridstackNode;
|
||||
const id = element.getAttribute("data-grid-item-id") ?? node?.id;
|
||||
const type = element.getAttribute("data-grid-item-type");
|
||||
if (!node || !id || (type !== "item" && type !== "section")) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: String(id),
|
||||
type,
|
||||
x: node.x ?? 0,
|
||||
y: node.y ?? 0,
|
||||
w: node.w ?? 1,
|
||||
h: node.h ?? 1,
|
||||
minW: node.minW,
|
||||
minH: node.minH,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const decorateGrid = (grid: GridStack | undefined, placements: readonly SectionGridPlacement[]) => {
|
||||
if (!grid?.el) return;
|
||||
const placementById = new Map(placements.map((placement) => [placement.id, placement]));
|
||||
grid.el.classList.add("grid-stack-editing");
|
||||
for (const item of grid.getGridItems()) {
|
||||
const id = item.getAttribute("data-grid-item-id") ?? item.gridstackNode?.id;
|
||||
const placement = id ? placementById.get(String(id)) : undefined;
|
||||
decorateItem(item, placement?.type ?? getElementItemType(item));
|
||||
}
|
||||
};
|
||||
|
||||
const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["type"] | undefined) => {
|
||||
const node = element.gridstackNode;
|
||||
if (!node?.id) return;
|
||||
|
||||
element.setAttribute("data-grid-item-id", String(node.id));
|
||||
if (type) element.setAttribute("data-grid-item-type", type);
|
||||
element.setAttribute("data-grid-x", String(node.x ?? 0));
|
||||
element.setAttribute("data-grid-y", String(node.y ?? 0));
|
||||
element.setAttribute("data-grid-w", String(node.w ?? 1));
|
||||
element.setAttribute("data-grid-h", String(node.h ?? 1));
|
||||
decorateGridResizeHandles(element, { minW: node.minW, minH: node.minH });
|
||||
};
|
||||
|
||||
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
|
||||
const type = element.getAttribute("data-grid-item-type");
|
||||
return type === "item" || type === "section" ? type : undefined;
|
||||
};
|
||||
|
||||
const arePlacementsEqual = (first: readonly SectionGridPlacement[], second: readonly SectionGridPlacement[]) => {
|
||||
if (first.length !== second.length) return false;
|
||||
const secondById = new Map(second.map((placement) => [placement.id, placement]));
|
||||
return first.every((placement) => {
|
||||
const other = secondById.get(placement.id);
|
||||
return (
|
||||
other !== undefined &&
|
||||
placement.type === other.type &&
|
||||
placement.x === other.x &&
|
||||
placement.y === other.y &&
|
||||
placement.w === other.w &&
|
||||
placement.h === other.h
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const uniqueGrids = (grids: Array<GridStack | undefined>) =>
|
||||
Array.from(new Set(grids.filter((grid): grid is GridStack => grid !== undefined)));
|
||||
|
||||
const restoreContentHeight = (grid: GridStack | undefined) => {
|
||||
if (!grid || (grid.opts.maxRow ?? 0) > 0) return;
|
||||
const contentRowCount = Math.max(1, ...getGridPlacements(grid).map((placement) => placement.y + placement.h));
|
||||
grid.updateOptions({ minRow: contentRowCount });
|
||||
setViewportRowCount(grid, contentRowCount);
|
||||
};
|
||||
|
||||
const setViewportRowCount = (grid: GridStack, rowCount: number) => {
|
||||
const viewport = grid.el.parentElement;
|
||||
if (!viewport?.hasAttribute("data-section-id")) return;
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(rowCount)}px`);
|
||||
};
|
||||
|
||||
const getTargetAncestorSectionIds = (
|
||||
sections: ReturnType<typeof useRequiredBoard>["sections"],
|
||||
layoutId: string,
|
||||
targetSectionId: string,
|
||||
) => {
|
||||
const parentById = new Map(
|
||||
sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.flatMap((section) => {
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout ? ([[section.id, layout.parentSectionId]] as const) : [];
|
||||
}),
|
||||
);
|
||||
const ancestors = new Set<string>();
|
||||
let current: string | undefined = targetSectionId;
|
||||
while (current && !ancestors.has(current)) {
|
||||
ancestors.add(current);
|
||||
current = parentById.get(current);
|
||||
}
|
||||
return ancestors;
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { GridItemHTMLElement } from "gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useIntegrations } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
releaseContainer: (id: string, container: HTMLElement) => void;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* One portal registry owns every edit-mode item on the board. Its content
|
||||
* containers are stable even when GridStack replaces or transfers the outer
|
||||
* positioning shell, so moving between grids does not remount widget content.
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const integrations = useIntegrations();
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "grid-stack-item-content";
|
||||
container.style.overflow = "visible";
|
||||
containersRef.current.set(id, container);
|
||||
setContainers(new Map(containersRef.current));
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const releaseContainer = useCallback((id: string, container: HTMLElement) => {
|
||||
// Controlled cross-grid moves remove the source shell before the target
|
||||
// adopts this container. Wait until all grid effects in the commit ran.
|
||||
queueMicrotask(() => {
|
||||
if (container.isConnected || containersRef.current.get(id) !== container) return;
|
||||
containersRef.current.delete(id);
|
||||
setContainers(new Map(containersRef.current));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
acquireContainer,
|
||||
releaseContainer,
|
||||
}),
|
||||
[acquireContainer, announce, integrations, releaseContainer],
|
||||
);
|
||||
|
||||
return (
|
||||
<GridPortalHostContext.Provider value={value}>
|
||||
{children}
|
||||
{Array.from(containers, ([id, container]) => {
|
||||
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
|
||||
return ownerSectionId
|
||||
? createPortal(
|
||||
<GridPortalContent
|
||||
itemId={id}
|
||||
ownerSectionId={ownerSectionId}
|
||||
container={container}
|
||||
integrations={integrations}
|
||||
announce={announce}
|
||||
/>,
|
||||
container,
|
||||
id,
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
|
||||
<span key={announcement.id}>{announcement.message}</span>
|
||||
</div>
|
||||
</GridPortalHostContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBoardGridPortalHost = () => {
|
||||
const context = useContext(GridPortalHostContext);
|
||||
if (!context) throw new Error("BoardGridPortalHost is required");
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GridPortalContentProps {
|
||||
itemId: string;
|
||||
ownerSectionId: string;
|
||||
container: HTMLElement;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, announce }: GridPortalContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const entry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const section = toGridSection(rawSection, currentLayoutId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const itemElement = container.parentElement as GridItemHTMLElement | null;
|
||||
if (!itemElement) return;
|
||||
|
||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement, {
|
||||
minW: itemElement.gridstackNode?.minW,
|
||||
minH: itemElement.gridstackNode?.minH,
|
||||
});
|
||||
}, [container, ownerSectionId]);
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "dynamic"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "dynamic" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
);
|
||||
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
announce,
|
||||
}}
|
||||
>
|
||||
<SectionContentItem item={entry} integrations={integrations} />
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
): Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem | null => {
|
||||
if (!section) return null;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...section,
|
||||
...layout,
|
||||
type: "section",
|
||||
};
|
||||
};
|
||||
|
||||
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
|
||||
const item = board.items.find((candidate) => candidate.id === entryId);
|
||||
if (item) {
|
||||
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
|
||||
}
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.kind === "dynamic" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "dynamic") return null;
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface GridResizeMinimum {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
|
||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||
const direction = Array.from(handle.classList).find((className) =>
|
||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||
);
|
||||
if (!direction) continue;
|
||||
const directionName = direction.replace("ui-resizable-", "");
|
||||
|
||||
handle.setAttribute(
|
||||
"data-testid",
|
||||
directionName === "se" ? "board-grid-resize-handle" : `board-grid-resize-handle-${directionName}`,
|
||||
);
|
||||
handle.setAttribute("data-grid-resize-direction", directionName);
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
.viewport {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.staticGrid,
|
||||
.editorGrid {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.editorGrid {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorGrid:not(:global(.grid-stack-editing)) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
width 220ms ease,
|
||||
height 220ms ease;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editorEntry:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.editorEntry:focus-visible,
|
||||
.editorEntry[data-keyboard-editing="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.dragAffordance {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
z-index: 7;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
pointer-events: none;
|
||||
place-items: center;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0.8;
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.dragAffordance::before {
|
||||
width: 12px;
|
||||
height: 8px;
|
||||
content: "";
|
||||
background-image: radial-gradient(circle, currentColor 1.25px, transparent 1.5px);
|
||||
background-size: 6px 4px;
|
||||
}
|
||||
|
||||
.editorEntry:hover .dragAffordance,
|
||||
.editorEntry:focus-visible .dragAffordance,
|
||||
.editorEntry[data-keyboard-editing="true"] .dragAffordance {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editorLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorLoadingItem {
|
||||
background-color: var(--mantine-color-default);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.liveRegion {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.staticItem,
|
||||
.dragAffordance {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { SectionContent } from "../content";
|
||||
import { useCollapsedSectionIds } from "../section-collapse";
|
||||
import { SectionProvider, useSectionContext } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
const GridEditor = dynamic(loadGridEditorAsync, {
|
||||
loading: GridEditorLoading,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function GridEditorLoading() {
|
||||
const t = useI18n();
|
||||
const { items, innerSections } = useSectionContext();
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="output"
|
||||
className={classes.editorLoading}
|
||||
data-testid="board-grid-editor-loading"
|
||||
aria-label={t("common.action.loading")}
|
||||
>
|
||||
{[...items, ...innerSections].map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.editorLoadingItem}
|
||||
style={getLogicalItemStyle({
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<LoadingOverlay visible />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
columnCount: number;
|
||||
requestedRowCount?: number;
|
||||
label: string;
|
||||
railPlacement?: "main" | "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SectionGrid = ({
|
||||
section,
|
||||
columnCount,
|
||||
requestedRowCount = 0,
|
||||
label,
|
||||
railPlacement = "main",
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const { announce, integrations } = useBoardGridPortalHost();
|
||||
const collapsedSectionIds = useCollapsedSectionIds();
|
||||
const minimumBySectionId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getDynamicSectionMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
);
|
||||
|
||||
const placements = useMemo(
|
||||
() =>
|
||||
[...items, ...innerSections].map((item): SectionGridPlacement => {
|
||||
const minimum = item.type === "section" ? minimumBySectionId.get(item.id) : undefined;
|
||||
return normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
minW: minimum?.width,
|
||||
minH: minimum?.height,
|
||||
},
|
||||
columnCount,
|
||||
);
|
||||
}),
|
||||
[columnCount, innerSections, items, minimumBySectionId],
|
||||
);
|
||||
|
||||
const displayPlacements = useMemo(() => {
|
||||
const directCollapsedIds = new Set(
|
||||
placements
|
||||
.filter(
|
||||
(placement) =>
|
||||
placement.type === "section" &&
|
||||
collapsedSectionIds.has(placement.id) &&
|
||||
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
|
||||
)
|
||||
.map((placement) => placement.id),
|
||||
);
|
||||
if (directCollapsedIds.size === 0) return placements;
|
||||
|
||||
return getCollapsedDisplayLayout(placements, {
|
||||
columnCount,
|
||||
collapsedItemIds: directCollapsedIds,
|
||||
});
|
||||
}, [collapsedSectionIds, columnCount, innerSections, placements]);
|
||||
|
||||
const placementById = useMemo(
|
||||
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
||||
[displayPlacements],
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount);
|
||||
const fixedRowCount = section.kind === "dynamic";
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items: displayedItems,
|
||||
innerSections: displayedInnerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount: fixedRowCount ? rowCount : null,
|
||||
announce,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
w={logicalWidth}
|
||||
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
fixedRowCount={fixedRowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("grid-stack", classes.editorGrid)}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getDynamicSectionMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
) => {
|
||||
const directItems = board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId ? [layout] : [];
|
||||
});
|
||||
const directSections = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "dynamic") return [];
|
||||
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId ? [layout] : [];
|
||||
});
|
||||
const children = [...directItems, ...directSections];
|
||||
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const withPlacement = <
|
||||
TItem extends {
|
||||
id: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
>(
|
||||
item: TItem,
|
||||
placement: SectionGridPlacement | undefined,
|
||||
): TItem => {
|
||||
if (!placement) return item;
|
||||
return {
|
||||
...item,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import { act, createElement } from "react";
|
||||
import type { ReactNode } 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 { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
|
||||
import { FixedGridItem } from "../fixed-grid-item";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
announce: vi.fn(),
|
||||
commitSectionGrid: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
{
|
||||
id: "weather",
|
||||
type: "item" as const,
|
||||
kind: "weather" as const,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
advancedOptions: {
|
||||
title: null as string | null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clock",
|
||||
type: "item" as const,
|
||||
kind: "clock" as const,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
advancedOptions: {
|
||||
title: null as string | null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [mocks.editMode],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "layout",
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: Record<string, string>) =>
|
||||
key === "item.moveResize.entryLabel" ? `${values?.name}, column ${values?.column}, row ${values?.row}` : key,
|
||||
}));
|
||||
|
||||
vi.mock("../use-grid-layout-actions", () => ({
|
||||
useGridLayoutActions: () => ({
|
||||
commitSectionGrid: mocks.commitSectionGrid,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../section-context", () => ({
|
||||
useSectionContext: () => ({
|
||||
section: { id: "root", kind: "empty", options: {} },
|
||||
items: mocks.items,
|
||||
innerSections: [],
|
||||
columnCount: 3,
|
||||
maxRowCount: mocks.maxRowCount,
|
||||
announce: mocks.announce,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
Box: ({ children, ...props }: { children?: ReactNode }) => React.createElement("div", props, children),
|
||||
};
|
||||
});
|
||||
|
||||
describe("fixed grid item behavior", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.announce.mockReset();
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
mocks.editMode = true;
|
||||
mocks.maxRowCount = null;
|
||||
getWeatherMock().advancedOptions.title = null;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("moves with the keyboard and deterministically pushes a collision down", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
expect(mocks.announce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("uses shift plus an arrow to resize while honoring grid bounds", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowRight",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowLeft",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenLastCalledWith(
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
|
||||
getWeatherMock().advancedOptions.title = " Balcony forecast ";
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
|
||||
expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1");
|
||||
expect(entry.tabIndex).toBe(0);
|
||||
expect(entry.getAttribute("aria-describedby")).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
|
||||
mocks.editMode = false;
|
||||
renderWeather(root);
|
||||
|
||||
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
||||
expect(item).not.toBeNull();
|
||||
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.getAttribute("role")).toBeNull();
|
||||
|
||||
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not move or resize beyond a fixed section row boundary", () => {
|
||||
mocks.maxRowCount = 1;
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true })));
|
||||
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
expect(mocks.announce).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
});
|
||||
|
||||
test("collapsing an expanded box changes display geometry without mutating persisted placement", () => {
|
||||
const persisted = [
|
||||
{ id: "box", type: "section" as const, x: 0, y: 0, w: 2, h: 4 },
|
||||
{ id: "app", type: "item" as const, x: 0, y: 4, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
const displayed = getCollapsedDisplayLayout(persisted, {
|
||||
columnCount: 2,
|
||||
collapsedItemIds: new Set(["box"]),
|
||||
});
|
||||
|
||||
expect(displayed).toEqual([
|
||||
{ ...persisted[0], h: 1 },
|
||||
{ ...persisted[1], y: 1 },
|
||||
]);
|
||||
expect(persisted[0]?.h).toBe(4);
|
||||
expect(persisted[1]?.y).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
const renderWeather = (root: Root) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
FixedGridItem,
|
||||
{
|
||||
item: getWeatherMock() as never,
|
||||
},
|
||||
createElement("span", null, "Weather"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getWeatherMock = () => {
|
||||
const item = mocks.items[0];
|
||||
if (!item) throw new Error("Expected the weather item mock to exist");
|
||||
return item;
|
||||
};
|
||||
|
||||
const getEditorEntry = (container: HTMLElement) => {
|
||||
const entry = container.querySelector<HTMLDivElement>("[data-editor-grid-entry]");
|
||||
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
const startKeyboardEditing = (entry: HTMLDivElement) => {
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||
|
||||
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||
const initial = [first, second, third];
|
||||
const bounds = { columnCount: 4 };
|
||||
|
||||
describe("grid drop swapping", () => {
|
||||
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||
|
||||
expect(getDropSwapPlacements(initial, current, "first", bounds)).toEqual([
|
||||
{ ...first, x: 1 },
|
||||
{ ...second, x: 0 },
|
||||
third,
|
||||
]);
|
||||
});
|
||||
|
||||
test("swaps different-sized items when both destinations remain valid", () => {
|
||||
const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
|
||||
const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
|
||||
const current = [
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, y: 1 },
|
||||
];
|
||||
|
||||
expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, x: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
|
||||
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||
|
||||
expect(getDropSwapPlacements(initial, differentSize, "first", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
|
||||
});
|
||||
|
||||
test("does nothing when the item did not move", () => {
|
||||
expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { scheduleGridEditorWarmup } from "../grid-editor-loader";
|
||||
|
||||
describe("grid editor warm-up", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
test("waits for board hydration, a paint, and browser idle time", async () => {
|
||||
let animationFrameCallback: FrameRequestCallback | undefined;
|
||||
let idleCallback: IdleRequestCallback | undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
|
||||
idleCallback = callback;
|
||||
return 2;
|
||||
});
|
||||
vi.stubGlobal("cancelIdleCallback", vi.fn());
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
expect(animationFrameCallback).toBeUndefined();
|
||||
|
||||
const canvas = document.createElement("section");
|
||||
canvas.dataset.boardHydrated = "true";
|
||||
document.body.append(canvas);
|
||||
await vi.waitFor(() => expect(animationFrameCallback).toBeDefined());
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
|
||||
animationFrameCallback?.(0);
|
||||
expect(idleCallback).toBeDefined();
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
|
||||
idleCallback?.({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 10,
|
||||
});
|
||||
expect(loadAsync).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
});
|
||||
|
||||
test("does not load after cancellation", () => {
|
||||
let idleCallback: IdleRequestCallback | undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
|
||||
idleCallback = callback;
|
||||
return 2;
|
||||
});
|
||||
vi.stubGlobal("cancelIdleCallback", vi.fn());
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
cancel();
|
||||
idleCallback?.({ didTimeout: false, timeRemaining: () => 10 });
|
||||
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to a timeout when requestIdleCallback is unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const requestIdleCallbackDescriptor = Object.getOwnPropertyDescriptor(window, "requestIdleCallback");
|
||||
Reflect.deleteProperty(window, "requestIdleCallback");
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(loadAsync).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
vi.useRealTimers();
|
||||
if (requestIdleCallbackDescriptor) {
|
||||
Object.defineProperty(window, "requestIdleCallback", requestIdleCallbackDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
test("waits until the document becomes visible", () => {
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
|
||||
const animationFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
expect(animationFrame).not.toHaveBeenCalled();
|
||||
|
||||
visibilityState = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(animationFrame).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const gridDirectory = resolve(import.meta.dirname, "..");
|
||||
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
||||
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps every GridStack runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => specifier === "gridstack" || specifier.startsWith("gridstack/"))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
file: toSourceRelativePath(filePath),
|
||||
kind,
|
||||
specifier,
|
||||
}))
|
||||
.toSorted((first, second) =>
|
||||
`${first.file}:${first.specifier}:${first.kind}`.localeCompare(
|
||||
`${second.file}:${second.specifier}:${second.kind}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(runtimeImports).toEqual([
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack/dist/gridstack.min.css",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads the editor through next/dynamic only in the edit branch", () => {
|
||||
const sourceFile = parseSourceFile(sectionGridPath);
|
||||
const dynamicImport = sourceFile.statements.find(
|
||||
(statement): statement is ts.ImportDeclaration =>
|
||||
ts.isImportDeclaration(statement) &&
|
||||
ts.isStringLiteral(statement.moduleSpecifier) &&
|
||||
statement.moduleSpecifier.text === "next/dynamic",
|
||||
);
|
||||
|
||||
expect(dynamicImport?.importClause?.name?.text).toBe("dynamic");
|
||||
|
||||
const gridEditorDeclaration = findNode(
|
||||
sourceFile,
|
||||
(node): node is ts.VariableDeclaration =>
|
||||
ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "GridEditor",
|
||||
);
|
||||
expect(gridEditorDeclaration).toBeDefined();
|
||||
expect(gridEditorDeclaration?.initializer && ts.isCallExpression(gridEditorDeclaration.initializer)).toBe(true);
|
||||
|
||||
const dynamicCall = gridEditorDeclaration?.initializer;
|
||||
if (!dynamicCall || !ts.isCallExpression(dynamicCall)) {
|
||||
throw new Error("GridEditor must be initialized with next/dynamic");
|
||||
}
|
||||
|
||||
expect(ts.isIdentifier(dynamicCall.expression) && dynamicCall.expression.text === "dynamic").toBe(true);
|
||||
expect(
|
||||
dynamicCall.arguments.some((argument) => ts.isIdentifier(argument) && argument.text === "loadGridEditorAsync"),
|
||||
).toBe(true);
|
||||
expect(dynamicCall.arguments.some(hasDisabledSsrOption)).toBe(true);
|
||||
expect(dynamicCall.arguments.some(hasEditorLoadingOption)).toBe(true);
|
||||
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
||||
expect(
|
||||
getRuntimeImports(editorLoaderPath)
|
||||
.filter(({ specifier }) => specifier === "./grid-editor")
|
||||
.map(({ kind }) => kind),
|
||||
).toEqual(["dynamic"]);
|
||||
|
||||
const usages = findNodes(
|
||||
sourceFile,
|
||||
(node): node is ts.JsxSelfClosingElement =>
|
||||
ts.isJsxSelfClosingElement(node) && ts.isIdentifier(node.tagName) && node.tagName.text === "GridEditor",
|
||||
);
|
||||
expect(usages).toHaveLength(1);
|
||||
|
||||
const gridEditorUsage = usages[0];
|
||||
if (!gridEditorUsage) {
|
||||
throw new Error("GridEditor must be rendered once");
|
||||
}
|
||||
|
||||
const editConditional = findAncestor(gridEditorUsage, ts.isConditionalExpression);
|
||||
expect(
|
||||
editConditional !== undefined &&
|
||||
ts.isIdentifier(editConditional.condition) &&
|
||||
editConditional.condition.text === "isEditMode" &&
|
||||
isDescendantOf(gridEditorUsage, editConditional.whenTrue),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
interface RuntimeImport {
|
||||
filePath: string;
|
||||
kind: "dynamic" | "require" | "static";
|
||||
specifier: string;
|
||||
}
|
||||
|
||||
const getSourceFiles = (directory: string): string[] =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) return getSourceFiles(path);
|
||||
return /\.[cm]?[jt]sx?$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
|
||||
const getRuntimeImports = (filePath: string): RuntimeImport[] => {
|
||||
const sourceFile = parseSourceFile(filePath);
|
||||
const imports: RuntimeImport[] = [];
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && isRuntimeImportDeclaration(node)) {
|
||||
imports.push({ filePath, kind: "static", specifier: node.moduleSpecifier.text });
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
isRuntimeExportDeclaration(node)
|
||||
) {
|
||||
imports.push({ filePath, kind: "static", specifier: node.moduleSpecifier.text });
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
|
||||
) {
|
||||
const [argument] = node.arguments;
|
||||
if (node.arguments.length === 1 && argument && ts.isStringLiteral(argument)) {
|
||||
imports.push({
|
||||
filePath,
|
||||
kind: node.expression.kind === ts.SyntaxKind.ImportKeyword ? "dynamic" : "require",
|
||||
specifier: argument.text,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
!node.isTypeOnly &&
|
||||
ts.isExternalModuleReference(node.moduleReference) &&
|
||||
node.moduleReference.expression &&
|
||||
ts.isStringLiteral(node.moduleReference.expression)
|
||||
) {
|
||||
imports.push({ filePath, kind: "static", specifier: node.moduleReference.expression.text });
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return imports;
|
||||
};
|
||||
|
||||
const isRuntimeImportDeclaration = (declaration: ts.ImportDeclaration) => {
|
||||
const clause = declaration.importClause;
|
||||
if (!clause) return true;
|
||||
if (clause.isTypeOnly) return false;
|
||||
if (clause.name) return true;
|
||||
if (!clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) return true;
|
||||
if (clause.namedBindings.elements.length === 0) return true;
|
||||
return clause.namedBindings.elements.some((element) => !element.isTypeOnly);
|
||||
};
|
||||
|
||||
const isRuntimeExportDeclaration = (declaration: ts.ExportDeclaration) => {
|
||||
if (declaration.isTypeOnly) return false;
|
||||
if (!declaration.exportClause || ts.isNamespaceExport(declaration.exportClause)) return true;
|
||||
if (declaration.exportClause.elements.length === 0) return true;
|
||||
return declaration.exportClause.elements.some((element) => !element.isTypeOnly);
|
||||
};
|
||||
|
||||
const parseSourceFile = (filePath: string) =>
|
||||
ts.createSourceFile(filePath, readFileSync(filePath, "utf8"), ts.ScriptTarget.Latest, true, getScriptKind(filePath));
|
||||
|
||||
const getScriptKind = (filePath: string) => {
|
||||
if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
||||
if (filePath.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
||||
if (filePath.endsWith(".js") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs")) return ts.ScriptKind.JS;
|
||||
return ts.ScriptKind.TS;
|
||||
};
|
||||
|
||||
const hasDisabledSsrOption = (node: ts.Node) => {
|
||||
if (!ts.isObjectLiteralExpression(node)) return false;
|
||||
return node.properties.some(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
ts.isIdentifier(property.name) &&
|
||||
property.name.text === "ssr" &&
|
||||
property.initializer.kind === ts.SyntaxKind.FalseKeyword,
|
||||
);
|
||||
};
|
||||
|
||||
const hasEditorLoadingOption = (node: ts.Node) => {
|
||||
if (!ts.isObjectLiteralExpression(node)) return false;
|
||||
return node.properties.some(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
ts.isIdentifier(property.name) &&
|
||||
property.name.text === "loading" &&
|
||||
ts.isIdentifier(property.initializer) &&
|
||||
property.initializer.text === "GridEditorLoading",
|
||||
);
|
||||
};
|
||||
|
||||
const findNode = <TNode extends ts.Node>(
|
||||
root: ts.Node,
|
||||
predicate: (node: ts.Node) => node is TNode,
|
||||
): TNode | undefined => findNodes(root, predicate)[0];
|
||||
|
||||
const findNodes = <TNode extends ts.Node>(root: ts.Node, predicate: (node: ts.Node) => node is TNode) => {
|
||||
const matches: TNode[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (predicate(node)) matches.push(node);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(root);
|
||||
return matches;
|
||||
};
|
||||
|
||||
const findAncestor = <TNode extends ts.Node>(
|
||||
node: ts.Node,
|
||||
predicate: (candidate: ts.Node) => candidate is TNode,
|
||||
): TNode | undefined => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (predicate(current)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDescendantOf = (node: ts.Node, ancestor: ts.Node) => node.pos >= ancestor.pos && node.end <= ancestor.end;
|
||||
|
||||
const toSourceRelativePath = (filePath: string) => relative(nextjsSourceDirectory, filePath).replaceAll("\\", "/");
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||
|
||||
describe("grid resize handle decoration", () => {
|
||||
test("restores accessible hit-target markers after GridStack refreshes its handles", () => {
|
||||
const item = document.createElement("div");
|
||||
item.innerHTML = `
|
||||
<div class="ui-resizable-handle ui-resizable-e"></div>
|
||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||
`;
|
||||
|
||||
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||
|
||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||
expect(east?.dataset.testid).toBe("board-grid-resize-handle-e");
|
||||
expect(east?.dataset.gridResizeDirection).toBe("e");
|
||||
expect(east?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
export interface SectionGridPlacement extends GridPlacement {
|
||||
type: "item" | "section";
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export interface CommitSectionGridInput {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a complete section snapshot in one cache update. GridStack can move
|
||||
* several neighbours for one gesture, so persisting only the active item would
|
||||
* lose its collision result.
|
||||
*/
|
||||
export const useGridLayoutActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const commitSectionGrids = useCallback(
|
||||
(snapshots: readonly CommitSectionGridInput[]) => {
|
||||
const byId = new Map(
|
||||
snapshots.flatMap(({ layoutId, sectionId, placements }) =>
|
||||
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
|
||||
),
|
||||
);
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
if (!resolved || resolved.placement.type !== "item") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== item.layouts[index]) ? { ...item, layouts } : item;
|
||||
}),
|
||||
sections: previous.sections.map((innerSection) => {
|
||||
if (innerSection.kind !== "dynamic") return innerSection;
|
||||
const layouts = innerSection.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
|
||||
if (!resolved || resolved.placement.type !== "section") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== innerSection.layouts[index])
|
||||
? { ...innerSection, layouts }
|
||||
: innerSection;
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const commitSectionGrid = useCallback(
|
||||
(snapshot: CommitSectionGridInput) => {
|
||||
commitSectionGrids([snapshot]);
|
||||
},
|
||||
[commitSectionGrids],
|
||||
);
|
||||
|
||||
return { commitSectionGrid, commitSectionGrids };
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { SectionKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
kind: WidgetKind | SectionKind;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
}
|
||||
|
||||
export const GridStackItem = ({
|
||||
id,
|
||||
type,
|
||||
kind,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width,
|
||||
height,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
innerRef,
|
||||
children,
|
||||
...boxProps
|
||||
}: PropsWithChildren<Props>) => {
|
||||
useEffect(() => {
|
||||
if (!innerRef?.current?.gridstackNode) return;
|
||||
if (type !== "section") return;
|
||||
innerRef.current.gridstackNode.minW = minWidth;
|
||||
innerRef.current.gridstackNode.minH = minHeight;
|
||||
}, [minWidth, minHeight, innerRef, type]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
className={combineClasses("grid-stack-item", boxProps.className)}
|
||||
data-id={id}
|
||||
data-type={type}
|
||||
data-kind={kind}
|
||||
gs-x={xOffset}
|
||||
gs-y={yOffset}
|
||||
gs-w={width}
|
||||
gs-h={height}
|
||||
gs-min-w={minWidth}
|
||||
gs-min-h={minHeight}
|
||||
ref={innerRef as React.RefObject<HTMLDivElement>}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { SectionContent } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useGridstack } from "./use-gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const GridStack = ({ section, ...props }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||
|
||||
const { refs } = useGridstack(section, itemIds);
|
||||
|
||||
return (
|
||||
<SectionProvider value={{ section, items, innerSections, refs }}>
|
||||
<Box
|
||||
{...props}
|
||||
data-kind={section.kind}
|
||||
data-section-id={section.id}
|
||||
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
import { GridStack } from "@homarr/gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InitializeGridstackProps {
|
||||
section: Omit<Section, "items">;
|
||||
itemIds: string[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
};
|
||||
sectionColumnCount: number;
|
||||
}
|
||||
|
||||
export const initializeGridstack = ({ section, itemIds, refs, sectionColumnCount }: InitializeGridstackProps) => {
|
||||
if (!refs.wrapper.current) return false;
|
||||
// initialize gridstack
|
||||
const newGrid = refs.gridstack;
|
||||
newGrid.current = GridStack.init(
|
||||
{
|
||||
column: sectionColumnCount,
|
||||
margin: 10,
|
||||
cellHeight: 128,
|
||||
float: true,
|
||||
alwaysShowResizeHandle: true,
|
||||
acceptWidgets: true,
|
||||
staticGrid: true,
|
||||
minRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 1,
|
||||
maxRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 0,
|
||||
animate: false,
|
||||
styleInHead: true,
|
||||
disableRemoveNodeOnDrop: true,
|
||||
},
|
||||
// selector of the gridstack item (it's eather category or wrapper)
|
||||
`.grid-stack-${section.kind}[data-section-id='${section.id}']`,
|
||||
);
|
||||
const grid = newGrid.current;
|
||||
|
||||
// Must be used to update the column count after the initialization
|
||||
grid.column(sectionColumnCount, "none");
|
||||
|
||||
grid.batchUpdate();
|
||||
grid.removeAll(false);
|
||||
itemIds.forEach((id) => {
|
||||
const ref = refs.items.current[id]?.current;
|
||||
if (!ref) return;
|
||||
|
||||
grid.makeWidget(ref);
|
||||
});
|
||||
grid.batchUpdate(false);
|
||||
return true;
|
||||
};
|
||||
@@ -1,372 +0,0 @@
|
||||
import type { Ref, RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useMarkSectionAsReady } from "~/app/[locale]/boards/(content)/_ready-context";
|
||||
import { useItemActions } from "../../items/item-actions";
|
||||
import { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
export interface UseGridstackRefs {
|
||||
wrapper: Ref<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
}
|
||||
|
||||
interface UseGristackReturnType {
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the size of a gridstack changes we need to update the css variables
|
||||
* so the gridstack items are displayed correctly
|
||||
* @param wrapper gridstack wrapper
|
||||
* @param gridstack gridstack object
|
||||
* @param width width of the section (column count)
|
||||
* @param height height of the section (row count)
|
||||
* @param isDynamic if the section is dynamic
|
||||
*/
|
||||
const handleResizeChange = (
|
||||
wrapper: HTMLDivElement,
|
||||
gridstack: GridStack,
|
||||
width: number,
|
||||
height: number,
|
||||
isDynamic: boolean,
|
||||
) => {
|
||||
wrapper.style.setProperty("--gridstack-column-count", width.toString());
|
||||
wrapper.style.setProperty("--gridstack-row-count", height.toString());
|
||||
|
||||
let cellHeight = wrapper.clientWidth / width;
|
||||
if (isDynamic) {
|
||||
cellHeight = wrapper.clientHeight / height;
|
||||
}
|
||||
|
||||
if (!isDynamic) {
|
||||
document.body.style.setProperty("--gridstack-cell-size", cellHeight.toString());
|
||||
}
|
||||
|
||||
gridstack.cellHeight(cellHeight);
|
||||
};
|
||||
|
||||
export const useGridstack = (section: Omit<Section, "items">, itemIds: string[]): UseGristackReturnType => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const markAsReady = useMarkSectionAsReady();
|
||||
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
||||
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
|
||||
|
||||
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
const gridRef = useRef<GridStack>(null);
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId)!;
|
||||
const columnCount =
|
||||
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
|
||||
? section.width
|
||||
: currentLayout.columnCount;
|
||||
|
||||
const itemRefKeys = Object.keys(itemRefs.current);
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
if (itemRefKeys.length !== itemIds.length) {
|
||||
// Remove items that are not in the itemIds
|
||||
// Otherwise when an item is removed and then another item is added, this foreach below will not run.
|
||||
itemRefKeys.forEach((id) => {
|
||||
if (!itemIds.includes(id)) {
|
||||
delete itemRefs.current[id];
|
||||
}
|
||||
});
|
||||
itemIds.forEach((id) => {
|
||||
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
|
||||
});
|
||||
}
|
||||
|
||||
const onChange = useCallback(
|
||||
(changedNode: GridStackNode) => {
|
||||
const id = changedNode.el?.getAttribute("data-id");
|
||||
const type = changedNode.el?.getAttribute("data-type");
|
||||
|
||||
if (!id || !type) return;
|
||||
|
||||
if (type === "item") {
|
||||
// Updates the react-query state
|
||||
moveAndResizeItem({
|
||||
itemId: id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "section") {
|
||||
moveAndResizeInnerSection({
|
||||
innerSectionId: id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
|
||||
},
|
||||
[moveAndResizeItem, moveAndResizeInnerSection],
|
||||
);
|
||||
const onAdd = useCallback(
|
||||
(addedNode: GridStackNode) => {
|
||||
const id = addedNode.el?.getAttribute("data-id");
|
||||
const type = addedNode.el?.getAttribute("data-type");
|
||||
|
||||
if (!id || !type) return;
|
||||
|
||||
if (type === "item") {
|
||||
// Updates the react-query state
|
||||
moveItemToSection({
|
||||
itemId: id,
|
||||
sectionId: section.id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "section") {
|
||||
moveInnerSectionToSection({
|
||||
innerSectionId: id,
|
||||
sectionId: section.id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
|
||||
},
|
||||
[moveItemToSection, moveInnerSectionToSection, section.id],
|
||||
);
|
||||
|
||||
// initialize the gridstack
|
||||
useEffect(() => {
|
||||
const isReady = initializeGridstack({
|
||||
section,
|
||||
itemIds,
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperElementRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
sectionColumnCount: columnCount,
|
||||
});
|
||||
|
||||
// If the section is ready mark it as ready
|
||||
// When all sections are ready the board is ready and will get visible
|
||||
if (isReady) {
|
||||
markAsReady(section.id);
|
||||
}
|
||||
|
||||
// Only run this effect when the section items change
|
||||
}, [itemIds.length, columnCount]);
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
const currentGrid = gridRef.current;
|
||||
// Add listener for moving items around in a wrapper
|
||||
currentGrid?.on("change", (_, nodes) => {
|
||||
nodes.forEach(onChange);
|
||||
|
||||
// For all dynamic section items that changed we want to update the inner gridstack
|
||||
nodes
|
||||
.filter((node) => node.el?.getAttribute("data-type") === "section")
|
||||
.forEach((node) => {
|
||||
const dynamicInnerGrid = node.el?.querySelector<GridHTMLElement>('.grid-stack[data-kind="dynamic"]');
|
||||
|
||||
if (!dynamicInnerGrid?.gridstack) return;
|
||||
|
||||
handleResizeChange(
|
||||
dynamicInnerGrid as HTMLDivElement,
|
||||
dynamicInnerGrid.gridstack,
|
||||
node.w ?? 1,
|
||||
node.h ?? 1,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Add listener for moving items in config from one wrapper to another
|
||||
currentGrid?.on("added", (_, nodes) => {
|
||||
nodes.forEach(onAdd);
|
||||
});
|
||||
|
||||
return () => {
|
||||
currentGrid?.off("change");
|
||||
currentGrid?.off("added");
|
||||
};
|
||||
}, [isEditMode, onAdd, onChange]);
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
* Toggle the gridstack to be static or not based on the edit mode
|
||||
*/
|
||||
useEffect(() => {
|
||||
gridRef.current?.setStatic(!isEditMode);
|
||||
}, [isEditMode]);
|
||||
|
||||
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
* We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sectionHeight) return;
|
||||
gridRef.current?.row(sectionHeight);
|
||||
}, [sectionHeight]);
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
*/
|
||||
useCssVariableConfiguration({
|
||||
columnCount,
|
||||
gridRef,
|
||||
wrapperRef: wrapperElementRef,
|
||||
width,
|
||||
height,
|
||||
isDynamic: section.kind === "dynamic",
|
||||
});
|
||||
|
||||
return {
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface UseCssVariableConfiguration {
|
||||
gridRef: UseGridstackRefs["gridstack"];
|
||||
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||
width: number;
|
||||
height: number;
|
||||
columnCount: number;
|
||||
isDynamic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
* @see gridstack.scss
|
||||
* @param gridRef reference to the gridstack object
|
||||
* @param wrapperRef reference to the wrapper of the gridstack
|
||||
* @param width width of the section
|
||||
* @param height height of the section
|
||||
* @param columnCount column count of the gridstack
|
||||
*/
|
||||
const useCssVariableConfiguration = ({
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
width,
|
||||
height,
|
||||
columnCount,
|
||||
isDynamic,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const onResize = useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
handleResizeChange(
|
||||
wrapperRef.current,
|
||||
gridRef.current,
|
||||
gridRef.current.getColumn(),
|
||||
gridRef.current.getRow(),
|
||||
isDynamic,
|
||||
);
|
||||
}, [wrapperRef, gridRef, isDynamic]);
|
||||
|
||||
useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
|
||||
wrapperRef.current.style.setProperty("--gridstack-column-count", gridRef.current.getColumn().toString());
|
||||
wrapperRef.current.style.setProperty("--gridstack-row-count", gridRef.current.getRow().toString());
|
||||
|
||||
let cellHeight = wrapperRef.current.clientWidth / gridRef.current.getColumn();
|
||||
if (isDynamic) {
|
||||
cellHeight = wrapperRef.current.clientHeight / gridRef.current.getRow();
|
||||
}
|
||||
|
||||
gridRef.current.cellHeight(cellHeight);
|
||||
}, [wrapperRef, gridRef, isDynamic]);
|
||||
|
||||
// Define widget-width by calculating the width of one column with mainRef width and column count
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("resize", onResize);
|
||||
const wrapper = wrapperRef.current;
|
||||
wrapper?.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.removeEventListener("resize", onResize);
|
||||
wrapper?.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [wrapperRef, gridRef, onResize]);
|
||||
|
||||
// Handle resize of inner sections when there size changes
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
}, [width, height, onResize]);
|
||||
|
||||
// Define column count by using the sectionColumnCount
|
||||
useEffect(() => {
|
||||
wrapperRef.current?.style.setProperty("--gridstack-column-count", columnCount.toString());
|
||||
}, [columnCount, wrapperRef]);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
interface SectionCollapseStorage {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
const legacyStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
export const sectionCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-v2-${sectionId}`;
|
||||
|
||||
/**
|
||||
* Legacy values represented whether the section was open even though the key
|
||||
* and database column called the value "collapsed". Migrate once into a
|
||||
* versioned key whose boolean has the corrected meaning.
|
||||
*/
|
||||
export const readSectionCollapsedFromStorage = (
|
||||
storage: SectionCollapseStorage,
|
||||
sectionId: string,
|
||||
fallback: boolean,
|
||||
) => {
|
||||
const currentKey = sectionCollapseStorageKey(sectionId);
|
||||
const legacyKey = legacyStorageKey(sectionId);
|
||||
const current = storage.getItem(currentKey);
|
||||
|
||||
if (current !== null) {
|
||||
storage.removeItem(legacyKey);
|
||||
return current === "true";
|
||||
}
|
||||
|
||||
const legacy = storage.getItem(legacyKey);
|
||||
if (legacy === null) return fallback;
|
||||
|
||||
const collapsed = legacy !== "true";
|
||||
storage.setItem(currentKey, String(collapsed));
|
||||
storage.removeItem(legacyKey);
|
||||
return collapsed;
|
||||
};
|
||||
|
||||
export const writeSectionCollapsedToStorage = (
|
||||
storage: SectionCollapseStorage,
|
||||
sectionId: string,
|
||||
collapsed: boolean,
|
||||
) => {
|
||||
storage.setItem(sectionCollapseStorageKey(sectionId), String(collapsed));
|
||||
storage.removeItem(legacyStorageKey(sectionId));
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from "./section-collapse-storage";
|
||||
|
||||
interface SectionCollapseContextValue {
|
||||
collapsedSectionIds: ReadonlySet<string>;
|
||||
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
||||
|
||||
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
board.sections.filter((section) => section.kind !== "empty").map((section) => [section.id, section.collapsed]),
|
||||
),
|
||||
);
|
||||
const collapsedByIdRef = useRef(collapsedById);
|
||||
collapsedByIdRef.current = collapsedById;
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedById((previous) => {
|
||||
const next = { ...previous };
|
||||
for (const section of board.sections) {
|
||||
if (section.kind === "empty" || section.id in next) continue;
|
||||
next[section.id] = section.collapsed;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [board.sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "unauthenticated") return;
|
||||
|
||||
setCollapsedById((previous) => {
|
||||
const next = { ...previous };
|
||||
for (const sectionId of Object.keys(next)) {
|
||||
next[sectionId] = readSectionCollapsedFromStorage(window.localStorage, sectionId, next[sectionId] ?? false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [status]);
|
||||
|
||||
const setCollapsed = useCallback(
|
||||
(sectionId: string, collapsed: boolean) => {
|
||||
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
|
||||
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
|
||||
|
||||
if (status === "authenticated") {
|
||||
mutate(
|
||||
{ sectionId, collapsed },
|
||||
{
|
||||
onError: () => {
|
||||
if (collapsedByIdRef.current[sectionId] !== collapsed) return;
|
||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: previousCollapsed };
|
||||
setCollapsedById((previous) => ({ ...previous, [sectionId]: previousCollapsed }));
|
||||
},
|
||||
},
|
||||
);
|
||||
} else if (status === "unauthenticated") {
|
||||
writeSectionCollapsedToStorage(window.localStorage, sectionId, collapsed);
|
||||
}
|
||||
},
|
||||
[mutate, status],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(
|
||||
() => ({
|
||||
collapsedSectionIds: new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
),
|
||||
setCollapsed,
|
||||
}),
|
||||
[collapsedById, setCollapsed],
|
||||
);
|
||||
|
||||
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
||||
return {
|
||||
isCollapsed,
|
||||
isVisuallyCollapsed: isCollapsed && !isEditMode,
|
||||
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollapsedSectionIds = () => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
return isEditMode ? EMPTY_SET : context.collapsedSectionIds;
|
||||
};
|
||||
|
||||
const EMPTY_SET = new Set<string>();
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
innerSections: DynamicSectionItem[];
|
||||
items: SectionItem[];
|
||||
refs: UseGridstackRefs;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const SectionContext = createContext<SectionContextProps | null>(null);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
readSectionCollapsedFromStorage,
|
||||
sectionCollapseStorageKey,
|
||||
writeSectionCollapsedToStorage,
|
||||
} from "../section-collapse-storage";
|
||||
|
||||
describe("section collapse storage", () => {
|
||||
test.each([
|
||||
["true", false],
|
||||
["false", true],
|
||||
])("inverts a legacy open value of %s exactly once", (legacyValue, expectedCollapsed) => {
|
||||
const sectionId = "media";
|
||||
const legacyKey = `homarr-section-collapsed-${sectionId}`;
|
||||
const storage = createStorage({ [legacyKey]: legacyValue });
|
||||
|
||||
expect(readSectionCollapsedFromStorage(storage, sectionId, false)).toBe(expectedCollapsed);
|
||||
expect(storage.getItem(legacyKey)).toBeNull();
|
||||
expect(storage.getItem(sectionCollapseStorageKey(sectionId))).toBe(String(expectedCollapsed));
|
||||
expect(readSectionCollapsedFromStorage(storage, sectionId, !expectedCollapsed)).toBe(expectedCollapsed);
|
||||
});
|
||||
|
||||
test("uses the corrected fallback when no browser preference exists", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
expect(readSectionCollapsedFromStorage(storage, "category", true)).toBe(true);
|
||||
expect(readSectionCollapsedFromStorage(storage, "dynamic", false)).toBe(false);
|
||||
});
|
||||
|
||||
test("writes only the versioned corrected state", () => {
|
||||
const sectionId = "media";
|
||||
const legacyKey = `homarr-section-collapsed-${sectionId}`;
|
||||
const storage = createStorage({ [legacyKey]: "true" });
|
||||
|
||||
writeSectionCollapsedToStorage(storage, sectionId, true);
|
||||
|
||||
expect(storage.getItem(legacyKey)).toBeNull();
|
||||
expect(storage.getItem(sectionCollapseStorageKey(sectionId))).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
const createStorage = (initial: Record<string, string> = {}) => {
|
||||
const values = new Map(Object.entries(initial));
|
||||
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSectionItemsForLayout } from "../use-open-section-apps";
|
||||
|
||||
describe("getSectionItemsForLayout", () => {
|
||||
test("includes items in recursively nested dynamic sections", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{
|
||||
id: "first",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "category" }],
|
||||
},
|
||||
{
|
||||
id: "second",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "first" }],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "category" }] },
|
||||
{ id: "nested", layouts: [{ layoutId: "desktop", sectionId: "second" }] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
|
||||
});
|
||||
|
||||
test("uses containment from only the requested responsive layout", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{ id: "other", kind: "category" },
|
||||
{
|
||||
id: "dynamic",
|
||||
kind: "dynamic",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", parentSectionId: "category" },
|
||||
{ layoutId: "desktop", parentSectionId: "other" },
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: "app",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", sectionId: "dynamic" },
|
||||
{ layoutId: "desktop", sectionId: "dynamic" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "mobile").map((item) => item.id)).toEqual(["app"]);
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not loop when malformed section containment is cyclic", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{
|
||||
id: "root",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "child" }],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
|
||||
},
|
||||
],
|
||||
items: [{ id: "app", layouts: [{ layoutId: "desktop", sectionId: "child" }] }],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "root", "desktop").map((item) => item.id)).toEqual(["app"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { filterByItemKind } from "./category/filter";
|
||||
|
||||
interface SectionItemLayout {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface SectionLayout {
|
||||
layoutId: string;
|
||||
parentSectionId: string;
|
||||
}
|
||||
|
||||
interface SectionTreeItem {
|
||||
layouts: readonly SectionItemLayout[];
|
||||
}
|
||||
|
||||
interface SectionTreeSection {
|
||||
id: string;
|
||||
kind: string;
|
||||
layouts?: readonly SectionLayout[];
|
||||
}
|
||||
|
||||
interface SectionTree<TItem extends SectionTreeItem> {
|
||||
items: readonly TItem[];
|
||||
sections: readonly SectionTreeSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every item contained by a section in one responsive layout. Dynamic
|
||||
* sections can be nested, so their descendants are traversed recursively.
|
||||
*/
|
||||
export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
|
||||
board: SectionTree<TItem>,
|
||||
rootSectionId: string,
|
||||
layoutId: string,
|
||||
) => {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
|
||||
for (const section of board.sections) {
|
||||
if (section.kind !== "dynamic") continue;
|
||||
|
||||
const layout = section.layouts?.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
|
||||
const children = childrenByParent.get(layout.parentSectionId) ?? [];
|
||||
children.push(section.id);
|
||||
childrenByParent.set(layout.parentSectionId, children);
|
||||
}
|
||||
|
||||
const includedSectionIds = new Set([rootSectionId]);
|
||||
const queue = [rootSectionId];
|
||||
|
||||
for (const sectionId of queue) {
|
||||
for (const childSectionId of childrenByParent.get(sectionId) ?? []) {
|
||||
if (includedSectionIds.has(childSectionId)) continue;
|
||||
|
||||
includedSectionIds.add(childSectionId);
|
||||
queue.push(childSectionId);
|
||||
}
|
||||
}
|
||||
|
||||
return board.items.filter((item) =>
|
||||
item.layouts.some((layout) => layout.layoutId === layoutId && includedSectionIds.has(layout.sectionId)),
|
||||
);
|
||||
};
|
||||
|
||||
export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const settings = useSettings();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const appIds = useMemo(() => {
|
||||
const items: Item[] = getSectionItemsForLayout(board, sectionId, currentLayoutId);
|
||||
return Array.from(new Set(filterByItemKind(items, settings, "app").map((item) => item.options.appId)));
|
||||
}, [board, currentLayoutId, sectionId, settings]);
|
||||
const { data: apps = [], isLoading } = clientApi.app.byIds.useQuery(appIds, {
|
||||
enabled: enabled && appIds.length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const open = useCallback(() => {
|
||||
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.openAllInNewTabs.title"),
|
||||
children: t("section.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [apps, openConfirmModal, t]);
|
||||
|
||||
return {
|
||||
open,
|
||||
isLoading: enabled && appIds.length > 0 && isLoading,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ForwardedRef, ReactNode } from "react";
|
||||
import type { FocusEventHandler, ForwardedRef, PointerEventHandler, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import type { ActionIconProps } from "@mantine/core";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
@@ -8,6 +8,8 @@ import { Link } from "@homarr/ui";
|
||||
type HeaderButtonProps = (
|
||||
| {
|
||||
onClick?: () => void;
|
||||
onFocus?: FocusEventHandler<HTMLButtonElement>;
|
||||
onPointerEnter?: PointerEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
| {
|
||||
href: string;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { boardViewportWidthCookieName } from "@homarr/boards/layout-selection";
|
||||
|
||||
export const ViewportHint = () => {
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const persist = () => {
|
||||
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
||||
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
};
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(persist, 150);
|
||||
};
|
||||
|
||||
persist();
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
@import "@homarr/gridstack/dist/gridstack.min.css";
|
||||
|
||||
:root {
|
||||
--gridstack-column-count: 12;
|
||||
--gridstack-row-count: 1;
|
||||
--gridstack-cell-size: 0;
|
||||
}
|
||||
|
||||
.ui-resizable-handle {
|
||||
// Override default z-index of 100 to prevent it from being on top of the header.
|
||||
// See https://github.com/homarr-labs/homarr/issues/1999
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
background-color: rgb(248, 249, 250) !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Define min size for gridstack items
|
||||
.grid-stack > .grid-stack-item {
|
||||
min-width: calc(100% / var(--gridstack-column-count));
|
||||
min-height: calc(100% / var(--gridstack-row-count));
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging {
|
||||
min-width: calc(var(--gridstack-cell-size) * 1px) !important;
|
||||
min-height: calc(var(--gridstack-cell-size) * 1px) !important;
|
||||
}
|
||||
|
||||
// Define fix size while dragging
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging[gs-w="#{$i}"] {
|
||||
width: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging[gs-h="#{$i}"] {
|
||||
height: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Styling for grid-stack main area
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-w="#{$i}"] {
|
||||
width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-min-w="#{$i}"] {
|
||||
min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-max-w="#{$i}"] {
|
||||
max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-h="#{$i}"] {
|
||||
height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-min-h="#{$i}"] {
|
||||
min-height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-max-h="#{$i}"] {
|
||||
max-height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-x="#{$i}"] {
|
||||
left: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-y="#{$i}"] {
|
||||
top: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
.grid-stack[data-kind="dynamic"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
// General gridstack styling
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content,
|
||||
.grid-stack > .grid-stack-item > .placeholder-content {
|
||||
inset: 10px;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .ui-resizable-se {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide empty wrapper (class is used when no items are inside and not in edit mode)
|
||||
*/
|
||||
.grid-stack-empty-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Locator, Page, Request, Response } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { stringify as stringifySuperJSON } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import {
|
||||
LOGICAL_GRID_CELL_SIZE,
|
||||
LOGICAL_GRID_GAP,
|
||||
MIN_ACCESSIBLE_CANVAS_SCALE,
|
||||
} from "../apps/nextjs/src/components/board/layout/constants";
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = {
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
const editRuntimeMarker = "homarr-edit-grid-v1";
|
||||
const gridStackPayloadMarker = "--gs-column-width";
|
||||
const logicalCellSize = LOGICAL_GRID_CELL_SIZE;
|
||||
const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE;
|
||||
|
||||
describe("Board grid", () => {
|
||||
test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, adminCredentials);
|
||||
const fixture = await seedBoardGridAsync(db, userId);
|
||||
const screenshotDirectory = await prepareScreenshotDirectoryAsync();
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
||||
let runtimeResources: ReturnType<typeof trackRuntimeResources> | undefined;
|
||||
|
||||
try {
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installLayoutShiftObserverAsync(page);
|
||||
runtimeResources = trackRuntimeResources(page);
|
||||
const pageErrors: Error[] = [];
|
||||
page.on("pageerror", (error) => {
|
||||
pageErrors.push(error);
|
||||
console.error(error.stack);
|
||||
});
|
||||
|
||||
const noScriptContext = await browser.newContext({
|
||||
javaScriptEnabled: false,
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
},
|
||||
});
|
||||
const noScriptPage = await noScriptContext.newPage();
|
||||
await noScriptPage.goto(`${baseUrl}/boards/${fixture.boardName}`);
|
||||
const serverRenderedCanvas = noScriptPage.getByTestId("board-canvas");
|
||||
await expect(serverRenderedCanvas).toHaveAttribute("data-board-hydrated", "false");
|
||||
const initialCanvasHeight = Number(await serverRenderedCanvas.getAttribute("data-canvas-initial-height"));
|
||||
expect(initialCanvasHeight).toBeGreaterThan(0);
|
||||
await expect(serverRenderedCanvas.locator(`[data-grid-item-id="${fixture.firstItemId}"]`)).toHaveCount(1);
|
||||
await noScriptContext.close();
|
||||
|
||||
await signInAsync(page, baseUrl, `/boards/${fixture.boardName}`);
|
||||
|
||||
const canvas = page.getByTestId("board-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-board-hydrated", "true");
|
||||
await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(0);
|
||||
|
||||
const mainSection = page.locator(`[data-section-id="${fixture.sectionId}"]`);
|
||||
const rail = page.getByRole("complementary", { name: "Left dashboard rail" });
|
||||
const railSection = page.locator(`[data-grid-item-id="${fixture.railSectionId}"]`);
|
||||
const railRootSection = rail.locator("[data-section-kind='empty']");
|
||||
const railItem = page.locator(`[data-grid-item-id="${fixture.railItemId}"]`);
|
||||
const dynamicSection = page.locator(`[data-grid-item-id="${fixture.dynamicSectionId}"]`);
|
||||
const nestedItem = page.locator(`[data-grid-item-id="${fixture.nestedItemId}"]`);
|
||||
const belowItem = page.locator(`[data-grid-item-id="${fixture.belowItemId}"]`);
|
||||
await expect(mainSection).toHaveAttribute("data-section-kind", "empty");
|
||||
await expect(mainSection).toHaveAttribute("data-rail-placement", "main");
|
||||
await expect(rail).toBeVisible();
|
||||
await expect(rail).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
|
||||
await expect(railSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(railItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "2");
|
||||
await expect(dynamicSection.getByText("Nested box", { exact: true })).toBeVisible();
|
||||
await expect(nestedItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "2");
|
||||
|
||||
const firstItem = page.locator(`[data-grid-item-id="${fixture.firstItemId}"]`);
|
||||
const secondItem = page.locator(`[data-grid-item-id="${fixture.secondItemId}"]`);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(firstItem).toHaveAttribute("data-grid-y", "0");
|
||||
await expect(firstItem).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(firstItem).toHaveAttribute("data-grid-h", "1");
|
||||
await expect(secondItem).toHaveAttribute("data-grid-x", "1");
|
||||
await expect(secondItem).toHaveAttribute("data-grid-y", "0");
|
||||
|
||||
const logicalTile = firstItem.locator("[data-grid-item-content]");
|
||||
await expect(logicalTile).toHaveCount(1);
|
||||
await expectFixedLogicalTileAsync(logicalTile);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile);
|
||||
const normalCanvasScale = await readCanvasScaleAsync(canvas);
|
||||
const secondItemBox = await expectBoundingBoxAsync(secondItem);
|
||||
expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo(
|
||||
(logicalCellPitch - logicalCellSize) * normalCanvasScale,
|
||||
1,
|
||||
);
|
||||
|
||||
const editToggle = page.getByTestId("board-edit-mode-toggle");
|
||||
await runtimeResources.waitForQuietAsync();
|
||||
expect(runtimeResources.urlsFor("read-only", "editor")).toEqual([]);
|
||||
expect(runtimeResources.urlsFor("read-only", "gridstack")).toEqual([]);
|
||||
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
|
||||
|
||||
await resetLayoutShiftScoreAsync(page);
|
||||
await releaseIdleCallbacksAsync(page);
|
||||
await expect(editToggle).toHaveAttribute("data-board-editor-preload-state", "ready", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await runtimeResources.waitForQuietAsync();
|
||||
expect(runtimeResources.urlsFor("read-only", "editor").length).toBeGreaterThan(0);
|
||||
expect(runtimeResources.urlsFor("read-only", "gridstack").length).toBeGreaterThan(0);
|
||||
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
|
||||
|
||||
// CSS zoom exercises Chromium's browser-zoom layout path without relying
|
||||
// on desktop browser chrome. At 200% the scale floor must preserve
|
||||
// magnification and expose horizontal scrolling instead of shrinking the
|
||||
// board by the inverse zoom factor.
|
||||
await setDocumentZoomAsync(page, 2);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "true");
|
||||
await expectFixedLogicalTileAsync(logicalTile);
|
||||
await expectUniformVisualScaleAsync(logicalTile);
|
||||
const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => {
|
||||
return Number.parseFloat(getComputedStyle(element).fontSize);
|
||||
});
|
||||
expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25);
|
||||
const zoomedTileBox = await expectBoundingBoxAsync(logicalTile);
|
||||
expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25);
|
||||
await expectHorizontalOverflowAsync(canvas);
|
||||
const overflowRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" }));
|
||||
await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeGreaterThanOrEqual(0);
|
||||
expect((await expectBoundingBoxAsync(rail)).y).toBeLessThanOrEqual(overflowRailY);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
||||
await setDocumentZoomAsync(page, 1);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
|
||||
const collapseDynamic = page.getByRole("button", {
|
||||
name: "Collapse: Nested box",
|
||||
exact: true,
|
||||
});
|
||||
await collapseDynamic.click();
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "1");
|
||||
await expect(nestedItem).toHaveCount(0);
|
||||
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "Expand: Nested box",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "2");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "2");
|
||||
await expect(nestedItem).toHaveCount(1);
|
||||
await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-read-only.png");
|
||||
|
||||
const viewMainSectionBox = await expectBoundingBoxAsync(mainSection);
|
||||
const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile);
|
||||
runtimeResources.enterEditMode();
|
||||
await editToggle.click();
|
||||
await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(4);
|
||||
await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0);
|
||||
await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0);
|
||||
await expectBoundingBoxToMatchAsync(mainSection, viewMainSectionBox);
|
||||
await expectBoundingBoxToMatchAsync(logicalTile, viewFirstItemBox);
|
||||
await runtimeResources.waitForQuietAsync();
|
||||
expect(runtimeResources.urlsFor("edit", "editor")).toEqual([]);
|
||||
expect(runtimeResources.urlsFor("edit", "gridstack")).toEqual([]);
|
||||
runtimeResources.stop();
|
||||
|
||||
await expect(mainSection).toHaveAttribute("role", "region");
|
||||
await expect(mainSection).toHaveAttribute("data-grid-editable", "true");
|
||||
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
|
||||
await expectTargetSizeAsync(getEditorEntry(firstItem));
|
||||
const dragAffordance = firstItem.getByTestId("board-grid-drag-affordance");
|
||||
const diagonalResizeHandle = firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]');
|
||||
const inertContent = firstItem.locator("[data-board-grid-inert-content]");
|
||||
await expect(dragAffordance).toBeVisible();
|
||||
await expect(dragAffordance).toHaveCSS("pointer-events", "none");
|
||||
await expectTargetSizeAsync(diagonalResizeHandle);
|
||||
await expectLogicalTargetSizeAsync(diagonalResizeHandle, 44);
|
||||
await expect(diagonalResizeHandle).toHaveCSS("transform", "none");
|
||||
await expect(inertContent).toHaveAttribute("inert", "");
|
||||
await expect(inertContent).toHaveCSS("pointer-events", "none");
|
||||
|
||||
await page.getByRole("button", { name: "Add board content" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "New category", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("menuitem", { name: "New item", exact: true }).click();
|
||||
const itemSelectDialog = page.getByRole("dialog", { name: "Choose item to add" });
|
||||
await expect(itemSelectDialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(itemSelectDialog).toHaveCount(0);
|
||||
|
||||
const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail);
|
||||
const canvasScale = await readCanvasScaleAsync(canvas);
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-x", "1");
|
||||
await expect(secondItem).toHaveAttribute("data-grid-x", "0");
|
||||
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-x", "4");
|
||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const railBoxAfterMainDrag = await expectBoundingBoxAsync(rail);
|
||||
expect(railBoxAfterMainDrag.x).toBeCloseTo(railBoxBeforeMainDrag.x, 1);
|
||||
expect(railBoxAfterMainDrag.width).toBeCloseTo(railBoxBeforeMainDrag.width, 1);
|
||||
await expect(railItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("aria-label", "Left dashboard rail");
|
||||
|
||||
const secondItemContent = secondItem.locator("[data-grid-item-content]");
|
||||
await expect(secondItemContent).toHaveCount(1);
|
||||
await secondItemContent.evaluate((element) => {
|
||||
element.setAttribute("data-e2e-portal-instance", "second-item-content");
|
||||
});
|
||||
|
||||
const railEditor = rail.locator("[data-grid-section-id]").first();
|
||||
const railGridBox = await expectBoundingBoxAsync(railEditor);
|
||||
await dragLocatorToAsync(
|
||||
page,
|
||||
getEditorEntry(secondItem),
|
||||
railGridBox.x + logicalCellPitch * canvasScale * 1.5,
|
||||
railGridBox.y + logicalCellSize * canvasScale * 0.5,
|
||||
);
|
||||
await expect(secondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(
|
||||
secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'),
|
||||
).toBeVisible();
|
||||
await expect(getEditorEntry(secondItem)).toHaveCount(1);
|
||||
expect(pageErrors).toEqual([]);
|
||||
|
||||
await moveEntryWithModalAsync(page, secondItem, /Dashboard canvas, destination \d+/);
|
||||
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
|
||||
"data-grid-section-id",
|
||||
fixture.sectionId,
|
||||
);
|
||||
await expect(getEditorEntry(secondItem)).toBeFocused();
|
||||
await moveEntryWithModalAsync(page, secondItem, /Left dashboard rail, destination \d+/);
|
||||
await expect(secondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(getEditorEntry(secondItem)).toBeFocused();
|
||||
await expect(
|
||||
secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const railKeyboardEntry = getEditorEntry(railItem);
|
||||
await railKeyboardEntry.focus();
|
||||
await railKeyboardEntry.press("Enter");
|
||||
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "true");
|
||||
await railKeyboardEntry.press("Shift+ArrowRight");
|
||||
await expect(railItem).toHaveAttribute("data-grid-w", "2");
|
||||
await railKeyboardEntry.press("Shift+ArrowLeft");
|
||||
await expect(railItem).toHaveAttribute("data-grid-w", "1");
|
||||
await railKeyboardEntry.press("ArrowRight");
|
||||
await expect(railItem).toHaveAttribute("data-grid-x", "1");
|
||||
await railKeyboardEntry.press("Escape");
|
||||
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false");
|
||||
|
||||
const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]');
|
||||
await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44);
|
||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale);
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||
|
||||
const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||
const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle);
|
||||
const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2;
|
||||
const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2;
|
||||
await page.mouse.move(resizeStartX, resizeStartY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 });
|
||||
const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||
expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1);
|
||||
await page.mouse.up();
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||
|
||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2);
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "3");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// The main canvas grows with its content instead of requiring a manual
|
||||
// row limit, and items can occupy empty rows below their original position.
|
||||
for (let targetRow = 1; targetRow <= 8; targetRow += 1) {
|
||||
await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), 0, logicalCellPitch * canvasScale + 2);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-y", String(targetRow));
|
||||
await waitForControlledGridReconciliationAsync(firstItem);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-y", String(targetRow));
|
||||
}
|
||||
await expect(firstItem).toHaveAttribute("data-grid-y", "8");
|
||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(belowItem))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" }));
|
||||
const scrolledRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
expect(scrolledRailY).toBeGreaterThanOrEqual(0);
|
||||
expect(scrolledRailY).toBeLessThanOrEqual(stickyRailY + 0.5);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
||||
|
||||
await setDocumentZoomAsync(page, 1.25);
|
||||
await expectFixedLogicalTileAsync(logicalTile);
|
||||
await expectUniformVisualScaleAsync(logicalTile);
|
||||
await expectTargetSizeAsync(getEditorEntry(firstItem));
|
||||
await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
|
||||
await setDocumentZoomAsync(page, 1);
|
||||
await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-edit-mode.png");
|
||||
|
||||
const dynamicLayoutBeforeDelete = await readGridLayoutAsync(dynamicSection);
|
||||
await dynamicSection.getByRole("button", { name: "Settings for Nested box" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", {
|
||||
name: "Remove section",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole("dialog", {
|
||||
name: "Remove section",
|
||||
})
|
||||
.getByRole("button", {
|
||||
name: "Confirm",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(dynamicSection).toHaveCount(0);
|
||||
await expect(nestedItem).toHaveCount(1);
|
||||
await expect(nestedItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
|
||||
"data-grid-section-id",
|
||||
fixture.sectionId,
|
||||
);
|
||||
await expect(nestedItem).toHaveAttribute("data-grid-x", String(dynamicLayoutBeforeDelete.x));
|
||||
await expect(nestedItem).toHaveAttribute("data-grid-y", String(dynamicLayoutBeforeDelete.y));
|
||||
|
||||
await page.getByTestId("board-edit-mode-toggle").click();
|
||||
await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
const reloadedCanvas = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]');
|
||||
const reloadedFirstItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.firstItemId}"]`);
|
||||
const reloadedSecondItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.secondItemId}"]`);
|
||||
const reloadedRailItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.railItemId}"]`);
|
||||
const reloadedDynamicSection = reloadedCanvas.locator(`[data-grid-item-id="${fixture.dynamicSectionId}"]`);
|
||||
const reloadedNestedItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.nestedItemId}"]`);
|
||||
await expect(reloadedCanvas).toHaveCount(1);
|
||||
await expect(reloadedFirstItem).toHaveAttribute("data-grid-x", "4");
|
||||
await expect(reloadedFirstItem).toHaveAttribute("data-grid-y", "8");
|
||||
await expect(reloadedRailItem).toHaveAttribute("data-grid-x", "1");
|
||||
await expect(reloadedRailItem).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(reloadedDynamicSection).toHaveCount(0);
|
||||
await expect(reloadedNestedItem).toHaveCount(1);
|
||||
await expect(reloadedNestedItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
|
||||
"data-grid-section-id",
|
||||
fixture.sectionId,
|
||||
);
|
||||
await expect(reloadedSecondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
|
||||
const persistedFirstLayout = await readGridLayoutAsync(reloadedFirstItem);
|
||||
const persistedSecondLayout = await readGridLayoutAsync(reloadedSecondItem);
|
||||
expect(gridLayoutsOverlap(persistedFirstLayout, persistedSecondLayout)).toBe(false);
|
||||
|
||||
await page.goto(`${baseUrl}/boards/${fixture.boardName}/settings?tab=layout`);
|
||||
const gutterPreview = page.getByLabel("Dashboard layout preview").filter({ visible: true });
|
||||
await expect(gutterPreview).toBeVisible();
|
||||
const rightGutterSwitch = page.getByRole("switch", { name: "Right sidebar" });
|
||||
await rightGutterSwitch.check();
|
||||
const rightGutterSlider = page.getByRole("slider", { name: "Right sidebar width" });
|
||||
await expect(rightGutterSlider).toHaveAttribute("aria-valuenow", "2");
|
||||
await rightGutterSlider.press("ArrowRight");
|
||||
await expect(rightGutterSlider).toHaveAttribute("aria-valuenow", "3");
|
||||
await captureBoardScreenshotAsync(page.locator("main"), screenshotDirectory, "board-layout-gutter-settings.png");
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes("/api/trpc/board.saveLayouts") && response.status() === 200,
|
||||
),
|
||||
rightGutterSwitch.locator("xpath=ancestor::form[1]").getByRole("button", { name: "Save changes" }).click(),
|
||||
]);
|
||||
|
||||
await page.goto(`${baseUrl}/boards/${fixture.boardName}`);
|
||||
const boardWithBothGutters = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]');
|
||||
await expect(boardWithBothGutters).toHaveCount(1);
|
||||
await expect(page.getByRole("complementary", { name: "Left dashboard rail" })).toBeVisible();
|
||||
const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" });
|
||||
await expect(emptyRightGutter).toHaveCount(1);
|
||||
expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0);
|
||||
expect(pageErrors).toEqual([]);
|
||||
} finally {
|
||||
runtimeResources?.stop();
|
||||
await browser?.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => {
|
||||
const boardId = createId();
|
||||
const boardName = "grid-e2e";
|
||||
const layoutId = createId();
|
||||
const sectionId = createId();
|
||||
const railSectionId = createId();
|
||||
const dynamicSectionId = createId();
|
||||
const firstItemId = createId();
|
||||
const secondItemId = createId();
|
||||
const belowItemId = createId();
|
||||
const nestedItemId = createId();
|
||||
const railItemId = createId();
|
||||
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId,
|
||||
isPublic: true,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Desktop",
|
||||
boardId,
|
||||
columnCount: 8,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values([
|
||||
{
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: railSectionId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "Pinned rail",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
options: stringifySuperJSON({
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: false,
|
||||
railPlacement: "left",
|
||||
columnCount: 2,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: dynamicSectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
options: stringifySuperJSON({
|
||||
title: "Nested box",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.sectionCollapseStates).values({
|
||||
userId: creatorId,
|
||||
sectionId: railSectionId,
|
||||
// Legacy categories stored true when the category was open.
|
||||
collapsed: true,
|
||||
});
|
||||
await db.insert(sqliteSchema.sectionLayouts).values({
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: firstItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: secondItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: belowItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: nestedItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: railItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{
|
||||
itemId: firstItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: secondItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: belowItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 2,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: nestedItemId,
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: railItemId,
|
||||
sectionId: railSectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
boardName,
|
||||
sectionId,
|
||||
railSectionId,
|
||||
dynamicSectionId,
|
||||
firstItemId,
|
||||
secondItemId,
|
||||
belowItemId,
|
||||
nestedItemId,
|
||||
railItemId,
|
||||
};
|
||||
};
|
||||
|
||||
const signInAsync = async (page: Page, baseUrl: string, callbackPath: string) => {
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("callbackUrl", callbackPath);
|
||||
await page.goto(loginUrl.href);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).fill(adminCredentials.password);
|
||||
await page.locator("button[type='submit']").click();
|
||||
await page.waitForURL(`${baseUrl}${callbackPath}`, { timeout: 15_000 });
|
||||
};
|
||||
|
||||
const prepareScreenshotDirectoryAsync = async () => {
|
||||
const configuredDirectory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
if (!configuredDirectory) return undefined;
|
||||
|
||||
const directory = resolvePath(configuredDirectory);
|
||||
await mkdir(directory, { recursive: true });
|
||||
return directory;
|
||||
};
|
||||
|
||||
const captureBoardScreenshotAsync = async (
|
||||
canvas: Locator,
|
||||
screenshotDirectory: string | undefined,
|
||||
fileName: string,
|
||||
) => {
|
||||
if (!screenshotDirectory) return;
|
||||
await canvas.screenshot({
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
path: resolvePath(screenshotDirectory, fileName),
|
||||
});
|
||||
};
|
||||
|
||||
type RuntimeResourcePhase = "read-only" | "edit";
|
||||
type RuntimePayload = "editor" | "gridstack";
|
||||
|
||||
interface RuntimeResource {
|
||||
phase: RuntimeResourcePhase;
|
||||
url: string;
|
||||
hasEditorPayload: boolean;
|
||||
hasGridStackPayload: boolean;
|
||||
}
|
||||
|
||||
const trackRuntimeResources = (page: Page) => {
|
||||
const resources: RuntimeResource[] = [];
|
||||
const pendingResponses = new Set<Promise<void>>();
|
||||
const inFlightRequests = new Set<Request>();
|
||||
const requestPhases = new WeakMap<Request, RuntimeResourcePhase>();
|
||||
const bodyErrors: Error[] = [];
|
||||
let phase: RuntimeResourcePhase = "read-only";
|
||||
let lastActivityAt = Date.now();
|
||||
let stopped = false;
|
||||
|
||||
const handleRequest = (request: Request) => {
|
||||
requestPhases.set(request, phase);
|
||||
if (!isRuntimeResourceType(request.resourceType())) return;
|
||||
inFlightRequests.add(request);
|
||||
lastActivityAt = Date.now();
|
||||
};
|
||||
|
||||
const handleRequestFailed = (request: Request) => {
|
||||
if (!inFlightRequests.delete(request)) return;
|
||||
lastActivityAt = Date.now();
|
||||
};
|
||||
|
||||
const handleResponse = (response: Response) => {
|
||||
const resourceKind = getRuntimeResourceKind(response);
|
||||
if (!resourceKind) return;
|
||||
|
||||
const request = response.request();
|
||||
const responsePhase = requestPhases.get(request) ?? phase;
|
||||
inFlightRequests.delete(request);
|
||||
lastActivityAt = Date.now();
|
||||
const task = readRuntimeResponseBodyAsync(page, response)
|
||||
.then((body) => {
|
||||
resources.push({
|
||||
phase: responsePhase,
|
||||
url: response.url(),
|
||||
hasEditorPayload: body.includes(Buffer.from(editRuntimeMarker)),
|
||||
hasGridStackPayload: body.includes(Buffer.from(gridStackPayloadMarker)),
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
bodyErrors.push(new Error(`Unable to inspect ${resourceKind} response ${response.url()}`, { cause: error }));
|
||||
});
|
||||
|
||||
pendingResponses.add(task);
|
||||
void task.finally(() => {
|
||||
pendingResponses.delete(task);
|
||||
lastActivityAt = Date.now();
|
||||
});
|
||||
};
|
||||
|
||||
page.on("request", handleRequest);
|
||||
page.on("requestfailed", handleRequestFailed);
|
||||
page.on("response", handleResponse);
|
||||
|
||||
return {
|
||||
enterEditMode() {
|
||||
phase = "edit";
|
||||
},
|
||||
urlsFor(resourcePhase: RuntimeResourcePhase, payload: RuntimePayload) {
|
||||
const key = payload === "editor" ? "hasEditorPayload" : "hasGridStackPayload";
|
||||
return [
|
||||
...new Set(
|
||||
resources
|
||||
.filter((resource) => resource.phase === resourcePhase && resource[key])
|
||||
.map((resource) => resource.url),
|
||||
),
|
||||
];
|
||||
},
|
||||
async waitForQuietAsync({ quietMs = 300, timeoutMs = 15_000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (true) {
|
||||
if (pendingResponses.size > 0) {
|
||||
await Promise.all(pendingResponses);
|
||||
}
|
||||
if (bodyErrors.length > 0) {
|
||||
throw new AggregateError(bodyErrors, "Unable to inspect one or more JavaScript or CSS responses");
|
||||
}
|
||||
|
||||
const quietFor = Date.now() - lastActivityAt;
|
||||
if (pendingResponses.size === 0 && inFlightRequests.size === 0 && quietFor >= quietMs) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
`JavaScript and CSS requests did not become quiet within ${timeoutMs}ms ` +
|
||||
`(${inFlightRequests.size} requests and ${pendingResponses.size} response bodies pending)`,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, quietMs - quietFor))));
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
page.off("request", handleRequest);
|
||||
page.off("requestfailed", handleRequestFailed);
|
||||
page.off("response", handleResponse);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readRuntimeResponseBodyAsync = async (page: Page, response: Response) => {
|
||||
try {
|
||||
return await response.body();
|
||||
} catch (originalError) {
|
||||
try {
|
||||
const fallbackResponse = await page.request.get(response.url(), {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!fallbackResponse.ok()) {
|
||||
throw new Error(`Fallback request returned HTTP ${fallbackResponse.status()}`, {
|
||||
cause: originalError,
|
||||
});
|
||||
}
|
||||
return await fallbackResponse.body();
|
||||
} catch (fallbackError) {
|
||||
throw new Error(
|
||||
`Unable to read or refetch runtime response ${response.url()}. Original read error: ${String(originalError)}`,
|
||||
{ cause: fallbackError },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRuntimeResourceType = (resourceType: string) => resourceType === "script" || resourceType === "stylesheet";
|
||||
|
||||
const getRuntimeResourceKind = (response: Response): "script" | "stylesheet" | null => {
|
||||
const resourceType = response.request().resourceType();
|
||||
if (resourceType === "script" || resourceType === "stylesheet") return resourceType;
|
||||
|
||||
const contentType = response.headers()["content-type"]?.toLowerCase() ?? "";
|
||||
if (contentType.includes("javascript") || contentType.includes("ecmascript")) return "script";
|
||||
if (contentType.includes("text/css")) return "stylesheet";
|
||||
return null;
|
||||
};
|
||||
|
||||
const expectBoundingBoxAsync = async (locator: Locator) => {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) {
|
||||
throw new Error("Expected locator to have a bounding box");
|
||||
}
|
||||
return box;
|
||||
};
|
||||
|
||||
const expectBoundingBoxToMatchAsync = async (
|
||||
locator: Locator,
|
||||
expected: { x: number; y: number; width: number; height: number },
|
||||
) => {
|
||||
const actual = await expectBoundingBoxAsync(locator);
|
||||
expect(actual.x).toBeCloseTo(expected.x, 0);
|
||||
expect(actual.y).toBeCloseTo(expected.y, 0);
|
||||
expect(actual.width).toBeCloseTo(expected.width, 0);
|
||||
expect(actual.height).toBeCloseTo(expected.height, 0);
|
||||
};
|
||||
|
||||
const installLayoutShiftObserverAsync = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
const target = window as typeof window & {
|
||||
homarrLayoutShiftScore?: number;
|
||||
homarrReleaseIdleCallbacks?: () => void;
|
||||
};
|
||||
const idleCallbacks = new Map<number, IdleRequestCallback>();
|
||||
let nextIdleCallbackId = 1;
|
||||
|
||||
target.homarrLayoutShiftScore = 0;
|
||||
target.requestIdleCallback = (callback) => {
|
||||
const callbackId = nextIdleCallbackId++;
|
||||
idleCallbacks.set(callbackId, callback);
|
||||
return callbackId;
|
||||
};
|
||||
target.cancelIdleCallback = (callbackId) => {
|
||||
idleCallbacks.delete(callbackId);
|
||||
};
|
||||
target.homarrReleaseIdleCallbacks = () => {
|
||||
const callbacks = [...idleCallbacks.values()];
|
||||
idleCallbacks.clear();
|
||||
for (const callback of callbacks) {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 50,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const shift = entry as PerformanceEntry & { hadRecentInput: boolean; value: number };
|
||||
if (!shift.hadRecentInput) {
|
||||
target.homarrLayoutShiftScore = (target.homarrLayoutShiftScore ?? 0) + shift.value;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
});
|
||||
};
|
||||
|
||||
const releaseIdleCallbacksAsync = async (page: Page) => {
|
||||
await page.evaluate(() => {
|
||||
const release = (
|
||||
window as typeof window & {
|
||||
homarrReleaseIdleCallbacks?: () => void;
|
||||
}
|
||||
).homarrReleaseIdleCallbacks;
|
||||
if (!release) {
|
||||
throw new Error("Idle callback release hook was not installed");
|
||||
}
|
||||
release();
|
||||
});
|
||||
};
|
||||
|
||||
const readLayoutShiftScoreAsync = async (page: Page) =>
|
||||
await page.evaluate(
|
||||
() => (window as typeof window & { homarrLayoutShiftScore?: number }).homarrLayoutShiftScore ?? 0,
|
||||
);
|
||||
|
||||
const resetLayoutShiftScoreAsync = async (page: Page) => {
|
||||
await page.evaluate(() => {
|
||||
(window as typeof window & { homarrLayoutShiftScore?: number }).homarrLayoutShiftScore = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const dragLocatorByAsync = async (page: Page, locator: Locator, deltaX: number, deltaY: number) => {
|
||||
const box = await expectBoundingBoxAsync(locator);
|
||||
await dragLocatorToAsync(page, locator, box.x + box.width / 2 + deltaX, box.y + box.height / 2 + deltaY);
|
||||
};
|
||||
|
||||
const waitForControlledGridReconciliationAsync = async (locator: Locator) => {
|
||||
await locator.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const dragLocatorToAsync = async (page: Page, locator: Locator, targetX: number, targetY: number) => {
|
||||
const box = await expectBoundingBoxAsync(locator);
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
const readCanvasScaleAsync = async (canvas: Locator) => {
|
||||
const scale = Number(await canvas.getAttribute("data-canvas-scale"));
|
||||
expect(Number.isFinite(scale)).toBe(true);
|
||||
expect(scale).toBeGreaterThan(0);
|
||||
return scale;
|
||||
};
|
||||
|
||||
const setDocumentZoomAsync = async (page: Page, zoom: number) => {
|
||||
await page.evaluate((nextZoom) => {
|
||||
document.documentElement.style.zoom = String(nextZoom);
|
||||
}, zoom);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const moveEntryWithModalAsync = async (page: Page, entry: Locator, targetName: RegExp) => {
|
||||
await entry.getByRole("button", { name: /^Settings for / }).click();
|
||||
await page.getByRole("menuitem", { name: "Move / resize item", exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Move / resize item" });
|
||||
await dialog.getByLabel("Section or rail").click();
|
||||
await page.getByRole("option", { name: targetName }).click();
|
||||
await dialog.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||
};
|
||||
|
||||
const getEditorEntry = (entry: Locator) => entry.locator("[data-editor-grid-entry]").first();
|
||||
|
||||
const expectFixedLogicalTileAsync = async (locator: Locator) => {
|
||||
await expect(locator).toHaveJSProperty("offsetWidth", logicalCellSize);
|
||||
await expect(locator).toHaveJSProperty("offsetHeight", logicalCellSize);
|
||||
};
|
||||
|
||||
const expectUniformVisualScaleAsync = async (locator: Locator) => {
|
||||
const scale = await locator.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const htmlElement = element as HTMLElement;
|
||||
return {
|
||||
horizontal: rect.width / htmlElement.offsetWidth,
|
||||
vertical: rect.height / htmlElement.offsetHeight,
|
||||
};
|
||||
});
|
||||
|
||||
expect(scale.horizontal).toBeCloseTo(scale.vertical, 3);
|
||||
};
|
||||
|
||||
const expectHorizontalOverflowAsync = async (locator: Locator) => {
|
||||
const dimensions = await locator.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return {
|
||||
clientWidth: htmlElement.clientWidth,
|
||||
scrollWidth: htmlElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
|
||||
};
|
||||
|
||||
const expectTargetSizeAsync = async (locator: Locator) => {
|
||||
const box = await expectBoundingBoxAsync(locator);
|
||||
expect(box.width).toBeGreaterThanOrEqual(24);
|
||||
expect(box.height).toBeGreaterThanOrEqual(24);
|
||||
};
|
||||
|
||||
const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => {
|
||||
const dimensions = await locator.evaluate((element) => ({
|
||||
width: (element as HTMLElement).offsetWidth,
|
||||
height: (element as HTMLElement).offsetHeight,
|
||||
}));
|
||||
expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize);
|
||||
expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize);
|
||||
};
|
||||
|
||||
const readGridLayoutAsync = async (locator: Locator) => {
|
||||
const [x, y, width, height] = await Promise.all([
|
||||
locator.getAttribute("data-grid-x"),
|
||||
locator.getAttribute("data-grid-y"),
|
||||
locator.getAttribute("data-grid-w"),
|
||||
locator.getAttribute("data-grid-h"),
|
||||
]);
|
||||
|
||||
expect(x).not.toBeNull();
|
||||
expect(y).not.toBeNull();
|
||||
expect(width).not.toBeNull();
|
||||
expect(height).not.toBeNull();
|
||||
|
||||
return {
|
||||
x: Number(x),
|
||||
y: Number(y),
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
};
|
||||
};
|
||||
|
||||
const gridLayoutsOverlap = (
|
||||
first: { x: number; y: number; width: number; height: number },
|
||||
second: { x: number; y: number; width: number; height: number },
|
||||
) =>
|
||||
first.x < second.x + second.width &&
|
||||
first.x + first.width > second.x &&
|
||||
first.y < second.y + second.height &&
|
||||
first.y + first.height > second.y;
|
||||
@@ -36,6 +36,8 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse
|
||||
name: input.username,
|
||||
provider: "credentials",
|
||||
password: await hashPasswordAsync(input.password),
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
});
|
||||
|
||||
await db.insert(sqliteSchema.groups).values({
|
||||
|
||||
@@ -29,12 +29,16 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
boardLanes,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getBoardLaneColumnCount,
|
||||
getRootSectionLane,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
@@ -317,6 +321,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
@@ -685,7 +691,25 @@ export const boardRouter = createTRPCRouter({
|
||||
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
let board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const missingGutterRoots = (["left", "right"] as const).filter(
|
||||
(lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0) &&
|
||||
!getRootSectionForLane(board, lane),
|
||||
);
|
||||
if (missingGutterRoots.length > 0) {
|
||||
await ctx.db.insert(sections).values(
|
||||
missingGutterRoots.map((lane) => ({
|
||||
id: createId(),
|
||||
boardId: board.id,
|
||||
kind: "empty" as const,
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
})),
|
||||
);
|
||||
board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
}
|
||||
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
|
||||
@@ -696,14 +720,6 @@ export const boardRouter = createTRPCRouter({
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
|
||||
const layoutToClone =
|
||||
@@ -714,13 +730,27 @@ export const boardRouter = createTRPCRouter({
|
||||
previous: {
|
||||
layoutId: layoutToClone.id,
|
||||
columnCount: layoutToClone.columnCount,
|
||||
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
|
||||
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
@@ -741,16 +771,25 @@ export const boardRouter = createTRPCRouter({
|
||||
for (const updatedLayout of updatedLayouts) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
|
||||
let updatedBoardLayout: ReturnType<typeof getUpdatedBoardLayout> | undefined;
|
||||
|
||||
if (dbLayout.columnCount !== updatedLayout.columnCount) {
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
if (
|
||||
dbLayout.columnCount !== updatedLayout.columnCount ||
|
||||
dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
|
||||
dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
|
||||
) {
|
||||
updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -796,6 +835,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.set({
|
||||
name: updatedLayout.name,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
breakpoint: updatedLayout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, updatedLayout.id));
|
||||
@@ -867,8 +908,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
@@ -993,8 +1034,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.update(schema.sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(schema.sections.id, section.id));
|
||||
@@ -1067,8 +1108,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
@@ -1203,8 +1244,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.update(sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(sections.id, section.id))
|
||||
@@ -1642,77 +1683,98 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
const getUpdatedBoardLayout = (
|
||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||
options: {
|
||||
previous: {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
};
|
||||
current: {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
};
|
||||
previous: BoardLayoutGeometry;
|
||||
current: BoardLayoutGeometry;
|
||||
},
|
||||
) => {
|
||||
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsCollection: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
|
||||
const elements = getElementsForLayout(board, options.previous.layoutId);
|
||||
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
|
||||
|
||||
for (const rootSection of rootSections) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items: elements,
|
||||
previousWidth: options.previous.columnCount,
|
||||
width: options.current.columnCount,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
|
||||
itemSectionLayoutsCollection.push(
|
||||
...board.items
|
||||
.map((item): InferInsertModel<typeof itemLayouts> | null => {
|
||||
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
|
||||
|
||||
if (!currentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
itemId: item.id,
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null),
|
||||
);
|
||||
|
||||
sectionLayoutsCollection.push(
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map((section): InferInsertModel<typeof sectionLayouts> | null => {
|
||||
const currentElement = result.items.find(
|
||||
(element) => element.type === "section" && element.id === section.id,
|
||||
);
|
||||
|
||||
if (!currentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
};
|
||||
})
|
||||
.filter((section) => section !== null),
|
||||
);
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
|
||||
}
|
||||
const mainRoot = rootByLane.get("main");
|
||||
if (!mainRoot) throw new Error(`Board "${board.id}" has no main canvas root`);
|
||||
|
||||
const sourceRootById = new Map(
|
||||
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
|
||||
);
|
||||
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
|
||||
boardLanes.map((lane) => [
|
||||
lane,
|
||||
lane !== "main" && getBoardLaneColumnCount(options.current, lane) === 0 ? "main" : lane,
|
||||
]),
|
||||
);
|
||||
const remappedElements = elements.map((element) => {
|
||||
const sourceLane = sourceRootById.get(element.sectionId);
|
||||
if (!sourceLane) return element;
|
||||
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
|
||||
const targetRoot = rootByLane.get(targetLane) ?? mainRoot;
|
||||
return {
|
||||
...element,
|
||||
sectionId: targetRoot.id,
|
||||
};
|
||||
});
|
||||
|
||||
const results = boardLanes.flatMap((lane) => {
|
||||
const root = rootByLane.get(lane);
|
||||
const width = getBoardLaneColumnCount(options.current, lane);
|
||||
if (!root || width === 0) return [];
|
||||
const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return [
|
||||
generateResponsiveGridFor({
|
||||
items: remappedElements,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId: root.id,
|
||||
}),
|
||||
];
|
||||
});
|
||||
const updatedElements = results.flatMap((result) => result.items);
|
||||
const updatedElementById = new Map(updatedElements.map((element) => [element.id, element]));
|
||||
|
||||
const itemSectionLayoutsCollection = board.items.flatMap((item): InferInsertModel<typeof itemLayouts>[] => {
|
||||
const currentElement = updatedElementById.get(item.id);
|
||||
if (!currentElement || currentElement.type !== "item") return [];
|
||||
|
||||
return [
|
||||
{
|
||||
itemId: item.id,
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const currentElement = updatedElementById.get(section.id);
|
||||
if (!currentElement || currentElement.type !== "section") return [];
|
||||
|
||||
return [
|
||||
{
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
itemSectionLayouts: itemSectionLayoutsCollection,
|
||||
@@ -1720,6 +1782,21 @@ const getUpdatedBoardLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
interface BoardLayoutGeometry {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
leftGutterColumnCount: number;
|
||||
rightGutterColumnCount: number;
|
||||
}
|
||||
|
||||
const getRootSectionForLane = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, lane: BoardLane) => {
|
||||
const roots = board.sections.filter(
|
||||
(section) => 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];
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
@@ -1830,7 +1907,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
parentSectionId: layout.parentSectionId,
|
||||
layoutId: layout.layoutId,
|
||||
})),
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? section.kind === "category",
|
||||
}),
|
||||
),
|
||||
items: items
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { sectionCollapseStates, sections } from "@homarr/db/schema";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +19,7 @@ export const sectionRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const section = await ctx.db.query.sections.findFirst({
|
||||
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
|
||||
where: eq(sections.id, input.sectionId),
|
||||
with: {
|
||||
collapseStates: {
|
||||
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
|
||||
@@ -31,6 +34,26 @@ export const sectionRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (section.kind === "empty") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Section cannot be collapsed id=${input.sectionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
|
||||
const parsedOptions =
|
||||
section.kind === "category"
|
||||
? categorySectionOptionsSchema.safeParse(rawOptions)
|
||||
: dynamicSectionOptionsSchema.safeParse(rawOptions);
|
||||
|
||||
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Section cannot be collapsed id=${input.sectionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (section.collapseStates.length === 0) {
|
||||
await ctx.db.insert(sectionCollapseStates).values({
|
||||
sectionId: section.id,
|
||||
|
||||
@@ -572,6 +572,35 @@ describe("getBoardByName should return board by name", () => {
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
it("normalizes legacy category behavior options in the response", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
await db
|
||||
.update(sections)
|
||||
.set({
|
||||
kind: "category",
|
||||
name: "Media",
|
||||
})
|
||||
.where(eq(sections.id, sectionId));
|
||||
|
||||
const result = await caller.getBoardByName({ name: "default" });
|
||||
const section = expectToBeDefined(result.sections[0]);
|
||||
|
||||
expect(result.id).toBe(boardId);
|
||||
expect(section.kind).toBe("category");
|
||||
if (section.kind === "category") {
|
||||
expect(section.collapsed).toBe(true);
|
||||
expect(section.options).toEqual({
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "main",
|
||||
columnCount: 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -882,6 +911,68 @@ describe("saveBoard should save full board", () => {
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
},
|
||||
);
|
||||
it("should add and update category behavior options", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
const sectionId = createId();
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "left",
|
||||
columnCount: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, sectionId),
|
||||
});
|
||||
|
||||
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
});
|
||||
});
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
@@ -1328,12 +1419,16 @@ const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 1400,
|
||||
});
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
@@ -1445,6 +1540,59 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
});
|
||||
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
|
||||
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
xOffset: 9,
|
||||
})
|
||||
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
|
||||
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const enabledLayout = expectToBeDefined(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) }));
|
||||
expect(enabledLayout.leftGutterColumnCount).toBe(2);
|
||||
const leftRoot = expectToBeDefined(
|
||||
await db.query.sections.findFirst({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
}),
|
||||
);
|
||||
const reflowedMainItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(reflowedMainItem.xOffset + reflowedMainItem.width).toBeLessThanOrEqual(8);
|
||||
|
||||
const gutterItemId = await addItemAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId: leftRoot.id,
|
||||
});
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
const returnedItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, gutterItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(returnedItem.sectionId).toBe(sectionId);
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user