Compare commits

...

38 Commits

Author SHA1 Message Date
d5096dbba3 Refactor Dashboard Tabs and Enhance CSS Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated CSS for dashboard tabs to improve layout and overflow handling, ensuring better visibility and usability.
- Replaced Typography's Text component with a custom ElipsisText component for improved text overflow management.
- Simplified tab selection logic in NavigationTabsContext, enhancing scrolling behavior when selecting tabs.
- Introduced a new utility function to scroll tabs into view, improving user experience during navigation.
2026-09-21 01:58:03 +01:00
e7f3e2a6d4 Enhance Keyboard Navigation in Dashboard Contexts
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced key handling improvements in ActionsContext to manage control key states more effectively.
- Added keyboard navigation functionality in NavigationTabsContext, allowing users to cycle through tabs using Ctrl+Tab and select tabs with number keys.
- Implemented cleanup for key event listeners to ensure proper state management and prevent memory leaks.
2026-09-21 00:22:51 +01:00
4f6f7cb881 Improve State Management and Location Handling in Dashboard Tabs
- Updated state handling in DashboardTabPanes to ensure null safety for tab state properties.
- Introduced a new utility function to determine if a location belongs to a leaving tab, enhancing tab navigation logic.
- Refactored location comparison logic to improve accuracy when checking for equality between tab states.
- Enhanced NavigationTabsContext to manage same-path query cleanup, improving tab restoration behavior.
2026-09-21 00:20:32 +01:00
d348d6f2b3 Enhance Navigation Context and Table State Management
- Introduced NavigationTabIdContext to improve tab identification and management within the Dashboard.
- Updated TableStateProvider to utilize the new context for better handling of tab-specific state, particularly in Electron environments.
- Enhanced ObjectListViewProvider to integrate persisted view handling based on the new context, improving user experience across different navigation scenarios.
- Refactored various components to streamline state management and improve overall performance.
2026-09-21 00:05:14 +01:00
44c8729787 Refactor DashboardTabs and ObjectDisplay for Enhanced Navigation and Scrolling
- Removed unused state references and optimized scrolling logic in DashboardTabs for improved performance.
- Updated ObjectDisplay to utilize action context for navigation, enhancing user interaction with object details.
- Introduced new utility functions for handling tab entries and navigation, streamlining tab management and improving user experience.
2026-09-20 23:36:22 +01:00
4c1766044f Update DashboardTabs for Improved ID Handling and Scrolling Logic
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added `htmlId` prop to `DashboardTabItem` for better accessibility and unique identification of tab items.
- Adjusted scrolling logic to account for offset, enhancing the user experience when navigating through tabs.
2026-09-20 22:38:52 +01:00
b54669fd26 Enhance DashboardTabs Interaction and Scrolling Behavior
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added unique IDs to dashboard tab items for improved accessibility and interaction.
- Implemented handleSelectTab function to manage tab selection and ensure proper scrolling behavior when tabs are selected, enhancing user experience.
2026-09-20 21:50:21 +01:00
396327f6ef Refactor DashboardTabs and Clean Up CSS Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed unnecessary CSS rules from App.css to streamline styles.
- Refactored DashboardTabs component to improve readability and maintainability by restructuring imports and simplifying the tab addition logic.
- Enhanced the handleAddTab function to ensure proper scrolling behavior when new tabs are added.
2026-09-20 21:31:17 +01:00
e446098a76 Big performance increase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-09-19 13:07:30 +01:00
24a3a03c65 Enhance Dashboard Functionality and Icon Integration
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated DashboardLayout to utilize Electron context for dynamic spacing adjustments.
- Enhanced About and Settings components to include icon keys for improved navigation clarity.
- Refined DashboardOverviewPage to generate dynamic icon keys based on page names.
- Improved DashboardTabs to support sidebar icons, enhancing visual consistency.
- Expanded NavigationTabsContext to manage icon keys for tabs, streamlining tab interactions.
- Updated sidebarIconMap to utilize component references for better performance and maintainability.
2026-09-19 01:57:42 +01:00
2b0565e69e Refine Dashboard Layout and Styles for Improved User Experience
- Adjusted the top positioning of the dashboard tab pane for better alignment.
- Updated the flex gap in the main content layout to enhance spacing consistency.
- Modified mouse enter delay for dashboard tab items to improve interaction responsiveness.
2026-09-19 01:38:28 +01:00
999c960339 Update Dashboard Tab Hover and Color Styles for Enhanced Visual Feedback
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted hover background color for inactive dashboard tab items to improve visibility.
- Refined color transition for dashboard tab close button, enhancing user interaction and visual consistency.
2026-09-18 23:57:13 +01:00
ae2ea0dc13 Refine Dashboard Tab Hover Styles for Improved Visual Feedback
- Updated hover styles for inactive dashboard tab items to enhance visual clarity and user interaction.
- Adjusted background and border properties for both light and dark modes, ensuring consistent appearance across themes.
2026-09-18 23:52:37 +01:00
877db10c09 Refactor Dashboard Tab Functionality and Enhance Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new store for managing active tab states, improving the responsiveness of tab interactions.
- Updated DashboardTabPanes and DashboardTabPreview components to utilize the new active state management, enhancing user experience.
- Enhanced CSS styles for dashboard tab previews and panes, ensuring better layout handling and visual consistency across themes.
- Improved tab preview capture logic to support dynamic theme changes, refining the overall rendering process.
- Adjusted various components to streamline the integration of tab previews and active state management.
2026-09-18 23:45:50 +01:00
0aee5b410f Enhance Dashboard Tab Styles and Capture Logic
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added isolation and improved z-index management in CSS for active and inactive dashboard tab panes, enhancing visual clarity.
- Updated styles for inactive tabs to improve layout handling and visibility transitions.
- Modified tab preview capture styles to ensure consistent display and layout during rendering, improving user experience.
2026-09-18 22:29:06 +01:00
969f5fece2 Add SnapDOM Integration and Refactor Dashboard Tab Functionality
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced @zumer/snapdom for enhanced tab preview capture, replacing html2canvas-pro for improved performance and reliability.
- Refactored DashboardTabPanes and DashboardTabs components to utilize the new tab preview logic, streamlining the rendering process.
- Updated CSS styles for dashboard tab previews, enhancing visual feedback and layout consistency.
- Removed deprecated preview capture logic and associated worker, simplifying the codebase.
- Enhanced NavigationTabsContext to manage tab previews and capturing state more effectively.
2026-09-18 21:57:35 +01:00
fcad667516 Add Tab Preview Functionality and Enhance Dashboard Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardTabPreview component for displaying tab previews, improving user interaction and visual feedback.
- Implemented tab preview capture logic using html2canvas-pro, allowing for dynamic rendering of tab content.
- Enhanced CSS styles for dashboard tab panes and previews, ensuring better layout management and visual consistency.
- Updated NavigationTabsContext to manage tab pane registration and preview capture layout, streamlining the tab interaction process.
- Refactored DashboardTabPanes and DashboardTabs components to integrate preview functionality and improve overall code structure.
2026-09-18 17:55:29 +01:00
f56646f906 Enhance Dashboard Navigation Fade Logic and Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new hook for managing delayed visibility of navigation elements, improving fade transitions.
- Updated CSS transitions for smoother visibility changes, ensuring better user experience during interactions.
- Refactored NavigationFade component to accept a className prop for enhanced styling flexibility.
- Adjusted logic for rendering window titles and dashboard items based on Electron context, improving overall navigation behavior.
2026-09-18 16:19:33 +01:00
89fc70a8ea Refactor Dashboard Component Styles for Consistency and Readability
- Updated various dashboard components to ensure consistent styling, including setting height to 100% for better layout management.
- Reformatted import statements for improved readability.
- Adjusted master filter formatting in ObjectTable components for enhanced clarity and consistency across the codebase.
- Improved overall code structure for better maintainability and visual alignment.
2026-09-18 16:10:08 +01:00
d9f3bb185f Update Dashboard Tab Pointer Events for Enhanced Interaction
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed pointer events for dashboard tab elements to improve user interaction, ensuring that the close button is responsive while maintaining a consistent experience across the dashboard.
- Adjusted CSS properties to refine the visual behavior of tab elements during user interactions.
2026-09-18 15:54:29 +01:00
e44f0b2ead Update Dashboard Tab Transition for Improved Visual Feedback
- Modified the opacity transition timing for dashboard elements to enhance visual responsiveness during interactions.
- Adjusted CSS to ensure smoother appearance and disappearance of elements, contributing to a more polished user experience.
2026-09-18 15:48:02 +01:00
48d2b0d15a Refine Dashboard Styles and Tab Functionality for Enhanced User Experience
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted CSS styles for dashboard elements, including margin and positioning tweaks for improved layout and visual consistency.
- Updated tab masking logic to enhance scrolling behavior and visual integration.
- Removed unnecessary divider components to streamline the dashboard navigation structure.
- Implemented new logic for handling tab scrolling, ensuring better responsiveness and user interaction.
2026-09-18 15:34:56 +01:00
0b4b8128de Enhance Dashboard Styles and Navigation with Improved Layout Adjustments
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added new CSS styles for dashboard menu container and tabs, improving visual alignment and spacing.
- Introduced logic to manage scroll edge fades for better user experience during tab navigation.
- Adjusted padding and margin properties for dashboard elements to enhance overall aesthetics and usability.
- Reformatted component code for improved readability and consistency.
2026-09-18 15:03:19 +01:00
abe183c82a Adjust Dashboard Tab Corner Positioning for Improved Alignment
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Modified the bottom positioning of dashboard tab corner elements for better visual alignment.
- Updated CSS styles to ensure consistent spacing across left and right corner elements, enhancing overall aesthetics.
2026-09-18 10:32:46 +01:00
7ab05c8c2d Update Window Configuration and Code Formatting
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the development server port from 5780 to 5173 for updated local development settings.
- Reformatted import statements for better readability and consistency.
- Simplified the timeout function call in handleWindowControl for improved code clarity.
2026-09-18 10:22:42 +01:00
9ca872e589 Enhance Dashboard Tabs with Sticky Visuals and Improved Masking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added CSS styles for sticky tab behavior, including new masking effects for better visual integration during scrolling.
- Updated JavaScript logic to manage sticky visuals and tab metrics, improving user interaction and responsiveness.
- Adjusted padding and layout properties for dashboard tab items to enhance overall aesthetics and usability.
2026-09-18 04:31:13 +01:00
84db1f19b7 Enhance Dashboard Tabs with Sticky Behavior and Improved Layout
- Introduced a new DashboardTabItem component to manage tab interactions, including drag-and-drop functionality.
- Added CSS styles for sticky tab behavior, ensuring better visibility and interaction during scrolling.
- Updated existing styles to improve layout consistency and responsiveness across dashboard components.
- Implemented metrics measurement for tab dimensions to enhance the user experience during tab interactions.
2026-09-18 03:24:56 +01:00
5498aa3368 Enhance Dashboard Tab Close Button with Transition Effect
- Added a transition effect to the opacity of the dashboard tab close button for smoother visual feedback on hover.
- Updated CSS styles to improve user interaction and visual consistency within the dashboard component.
2026-09-18 02:48:20 +01:00
251bb716cf Update Dashboard Sidebar Splitter Dimensions for Improved Layout
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted the default and maximum sidebar widths to enhance the layout and user experience within the Dashboard component.
- Set DEFAULT_SIDEBAR_PX to 285 and MAX_SIDEBAR_PX to 305 for better responsiveness.
2026-09-18 02:45:01 +01:00
d52eea3a41 Add Dashboard Development Controls and Update Navigation Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardDevControls component to manage API server connection states and provide visual feedback.
- Enhanced DashboardNavigation with improved layout and styling, including new transition effects for navigation elements.
- Adjusted CSS styles for better spacing and visibility of dashboard components, ensuring a more cohesive user experience.
2026-09-18 02:35:15 +01:00
f3af883bd2 Refine Dashboard Tab Styles and Adjust Margins
- Reduced the left margin for dashboard tab close buttons for improved spacing.
- Removed hover styles for the close button to streamline the design and enhance visual consistency.
2026-09-18 01:47:37 +01:00
5cc74bca18 Enhance Dashboard Styles with New Background and Corner Adjustments
- Added a new background style for the main content layout in light mode under macOS vibrancy for improved visual depth.
- Introduced additional styling for active dashboard tab items, including a new after pseudo-element for better visual distinction.
- Refined corner radius properties for dashboard tab items to enhance alignment and aesthetics.
- Simplified corner fill positioning using the inset property for more consistent layout behavior across components.
2026-09-18 01:42:15 +01:00
65417f75b8 Enhance Dashboard Navigation with New Modal Visibility Handling and Styling
- Introduced state management for modal visibility in the DashboardNavigation component to improve user interaction and layout responsiveness.
- Added new CSS styles for dashboard navigation elements, including transitions and layout adjustments for better visual clarity.
- Refactored the rendering logic to conditionally display navigation elements based on modal visibility, enhancing the overall user experience.
2026-09-18 01:42:07 +01:00
c39051a891 Refactor Dashboard Styles and Enhance Layout Consistency
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated CSS styles for macOS vibrancy modes, adjusting background colors and adding new layout properties for improved visual clarity.
- Enhanced dashboard tab components with new corner fill elements and refined dimensions for better alignment and responsiveness.
- Modified DashboardNavigation and DashboardTabs components to improve layout handling and user interaction, including adjustments to padding and flex properties.
- Introduced transitions for non-active dashboard tab items to enhance user experience during interactions.
2026-09-18 01:16:26 +01:00
d6c72c21e8 Refactor Dashboard Navigation and Tabs for Enhanced User Experience
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Restructured the DashboardNavigation component to separate leading and trailing navigation elements, improving layout and clarity.
- Updated CSS styles for navigation and dashboard tabs to enhance responsiveness and visual consistency.
- Implemented drag-and-drop functionality for dashboard tabs, allowing users to reorder tabs seamlessly.
- Introduced new styles for tab items and improved handling of drag states, enhancing the overall interaction experience.
2026-09-17 23:36:44 +01:00
7fef25d911 Enhance Dashboard Navigation with Object Navigation Hook
- Introduced `useObjectNavigationTabPage` hook to streamline navigation tab management for various dashboard components, improving consistency in object handling.
- Updated multiple components (DocumentTemplateDesign, EmailTemplateDesign, NoteInfo, GCodeFilePreview, ControlPrinter) to utilize the new hook for better object navigation and state management.
- Adjusted CSS styles for dashboard tabs to improve layout and responsiveness, enhancing user experience across devices.
2026-09-17 21:39:04 +01:00
2f9f42c9e2 Refactor tab management in NavigationTabsContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Simplified the logic for adding new tabs by directly appending the next tab to the current tabs array, improving code readability and maintainability.
- Removed unnecessary calculations for determining the insertion index, streamlining the tab addition process.
2026-09-17 20:46:26 +01:00
d16aa373be Implement Navigation Tabs and Dashboard Enhancements
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced NavigationTabsContext to manage tab state and navigation within the dashboard.
- Added DashboardTabs component for improved tabbed navigation, allowing users to add, close, and reorder tabs.
- Enhanced Dashboard layout to conditionally render tab panes based on the active tab, improving user experience.
- Updated various components to utilize navigation tab context, ensuring consistent title management and tab interactions.
- Implemented drag-and-drop functionality for tabs, enabling users to rearrange their workspace effectively.
- Refactored CSS styles for dashboard tabs and panes to ensure proper layout and responsiveness across devices.
2026-09-17 20:39:50 +01:00
108 changed files with 6797 additions and 4183 deletions

View File

@ -112,6 +112,98 @@
--webkit-user-select: none; --webkit-user-select: none;
} }
.dashboard-navigation-center {
position: relative;
flex: 1;
min-width: 0;
align-self: stretch;
display: flex;
align-items: center;
}
.dashboard-navigation-fade {
opacity: 1;
visibility: visible;
/* Incoming chrome waits for the outgoing fade, then fades in. */
transition:
opacity 0.2s ease 0.2s,
visibility 0s linear 0.2s;
}
.dashboard-navigation-fade-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
/*
Visibility must use a non-zero duration on hide. A 0s visibility
transition makes WebKit apply hidden immediately, so opacity never
animates and the chrome glitches out instead of fading.
*/
transition:
opacity 0.2s ease 0s,
visibility 0.2s linear 0s;
}
.dashboard-navigation-window-title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 16px;
font-size: 14px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.dashboard-navigation-center-border {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--color-header-border);
pointer-events: none;
opacity: 1;
/* Wait for outgoing chrome to finish fading before this line appears. */
transition: opacity 0.2s ease 0s;
}
.dashboard-navigation-center-border-hidden {
opacity: 0;
/* Stay through the gap, then fade out as incoming chrome fades in. */
transition: opacity 0.2s ease 0.2s;
}
.dashboard-menu-container {
position: relative;
height: 42px;
padding-top: 2px;
margin-left: 8px;
}
.dashboard-menu-container:after {
border-bottom: 1px solid var(--color-header-border);
content: '';
position: absolute;
left: -8px;
right: 0px;
bottom: 0;
}
.electron-navigation-leading,
.electron-navigation-trailing {
align-self: stretch;
height: 42px;
border-bottom: 1px solid var(--color-header-border);
}
.electron-navigation-trailing {
flex: 0 1 auto;
justify-content: flex-end;
}
.electron-body * { .electron-body * {
-webkit-user-select: none; -webkit-user-select: none;
-khtml-user-select: none; -khtml-user-select: none;
@ -145,18 +237,50 @@ html.macos-vibrancy .main-layout {
background: transparent !important; background: transparent !important;
} }
html.macos-vibrancy .light-mode {
--color-layout-background: rgba(255, 255, 255, 0.3);
}
html.macos-vibrancy .dark-mode {
--color-layout-background: rgba(0, 0, 0, 0.3);
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout { html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
background: rgba(255, 255, 255, 0.88) !important; background: var(--color-layout-background) !important;
position: relative;
} }
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout { html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
background: rgba(0, 0, 0, 0.88) !important; background: var(--color-layout-background) !important;
position: relative;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(10, 10, 10, 0.85) !important;
z-index: -1;
} }
html.macos-vibrancy .light-mode .ant-layout-sider { html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(244, 244, 244, 0.82) !important; background: rgba(244, 244, 244, 0.82) !important;
} }
html.macos-vibrancy .light-mode .ant-layout.main-content-layout:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85) !important;
z-index: -1;
}
html.macos-vibrancy .dark-mode .ant-layout-sider { html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.82) !important; background: rgba(10, 10, 10, 0.82) !important;
} }
@ -193,11 +317,11 @@ html.macos-vibrancy
} }
html.macos-vibrancy .dark-mode .electron-navigation-wrapper { html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
background: rgba(10, 10, 10, 0.9) !important; background: rgba(10, 10, 10, 0.85) !important;
} }
html.macos-vibrancy .light-mode .electron-navigation-wrapper { html.macos-vibrancy .light-mode .electron-navigation-wrapper {
background: rgba(255, 255, 255, 0.9) !important; background: rgba(255, 255, 255, 0.85) !important;
} }
html.macos-vibrancy .electron-navigation { html.macos-vibrancy .electron-navigation {
@ -367,6 +491,7 @@ code {
:root, :root,
.light-mode, .light-mode,
html:has(.light-mode) { html:has(.light-mode) {
--color-layout-background: #ffffff;
--kbd-color: #363636; --kbd-color: #363636;
--kbd-bg: #efefef; --kbd-bg: #efefef;
--kbd-inset: #e8e8e8; --kbd-inset: #e8e8e8;
@ -378,6 +503,7 @@ html:has(.light-mode) {
.dark-mode, .dark-mode,
html:has(.dark-mode) { html:has(.dark-mode) {
--color-layout-background: #000000;
--kbd-color: #ececec; --kbd-color: #ececec;
--kbd-bg: #3a3a3a; --kbd-bg: #3a3a3a;
--kbd-inset: #323232; --kbd-inset: #323232;
@ -1891,7 +2017,7 @@ body.objectKanbanColumnResizing * {
.electron-body .ant-modal-wrap, .electron-body .ant-modal-wrap,
.electron-body .ant-modal-mask, .electron-body .ant-modal-mask,
.electron-body .ant-drawer { .electron-body .ant-drawer {
top: 41px; top: 42px;
} }
@keyframes h-progress-macos-wave { @keyframes h-progress-macos-wave {
@ -3346,6 +3472,732 @@ body.objectKanbanColumnResizing * {
min-width: 20px; min-width: 20px;
} }
.dashboard-tabs {
flex: 1;
min-width: 0;
align-self: stretch;
height: 42px;
position: relative;
--tab-mask-fade: 14px;
}
.dashboard-tabs-wrap {
flex: 1;
min-width: 0;
height: 100%;
position: relative;
--tab-mask-left: 4px;
--tab-mask-right: 8px;
--tab-mask-fade-left: var(--tab-mask-fade);
--tab-mask-fade-right: var(--tab-mask-fade);
}
.dashboard-tabs-wrap .simplebar-content-wrapper {
overscroll-behavior-x: none;
}
.dashboard-tabs-wrap .simplebar-mask {
-webkit-clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left));
clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left));
-webkit-mask-image: linear-gradient(
to right,
transparent var(--tab-mask-left),
#000 calc(var(--tab-mask-left) + var(--tab-mask-fade-left)),
#000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade-right)),
transparent calc(100% - var(--tab-mask-right))
);
mask-image: linear-gradient(
to right,
transparent var(--tab-mask-left),
#000 calc(var(--tab-mask-left) + var(--tab-mask-fade-left)),
#000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade-right)),
transparent calc(100% - var(--tab-mask-right))
);
}
.dashboard-tabs[data-at-start] .dashboard-tabs-wrap {
--tab-mask-left: 0px;
--tab-mask-fade-left: 0px;
}
.dashboard-tabs[data-at-end] .dashboard-tabs-wrap {
--tab-mask-right: 0px;
--tab-mask-fade-right: 0px;
}
.dashboard-tabs-wrap::before,
.dashboard-tabs-wrap::after {
content: '';
position: absolute;
bottom: 0;
height: 1px;
pointer-events: none;
z-index: 2;
background: var(--color-header-border);
}
.dashboard-tabs-wrap::before {
left: 0;
width: calc(var(--tab-mask-left) + var(--tab-mask-fade-left));
-webkit-mask-image: linear-gradient(
to right,
#000 var(--tab-mask-left),
transparent calc(var(--tab-mask-left) + var(--tab-mask-fade-left))
);
mask-image: linear-gradient(
to right,
#000 var(--tab-mask-left),
transparent calc(var(--tab-mask-left) + var(--tab-mask-fade-left))
);
}
.dashboard-tabs-wrap::after {
right: 0.2px;
width: calc(var(--tab-mask-right) + var(--tab-mask-fade-right));
-webkit-mask-image: linear-gradient(
to left,
#000 var(--tab-mask-right),
transparent calc(var(--tab-mask-right) + var(--tab-mask-fade-right))
);
mask-image: linear-gradient(
to left,
#000 var(--tab-mask-right),
transparent calc(var(--tab-mask-right) + var(--tab-mask-fade-right))
);
}
.dashboard-tabs-wrap .simplebar-track.simplebar-vertical {
display: none;
}
.dashboard-tabs-inner {
width: max-content;
height: 100%;
min-width: fit-content;
width: 100%;
overflow: hidden;
align-items: flex-start;
box-sizing: border-box;
}
.dashboard-tabs-list {
position: relative;
display: flex;
align-items: flex-start;
gap: 4px;
padding-left: 20px;
height: 100%;
max-height: 42px;
}
.dashboard-tab-item-container {
height: 42px;
position: relative;
flex-shrink: 0;
}
.dashboard-tab-item-container-active {
border-bottom: none;
height: 42px;
}
.dashboard-tab-item-container:before {
content: '';
position: absolute;
top: 0;
left: -2px;
right: -2px;
bottom: 0;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tab-item-container:first-child:before {
left: -20px;
}
.dashboard-tab-item-container:last-child:before {
right: -20px;
}
.dashboard-tab-item-container-active + .dashboard-tab-item-container:before {
left: 16px;
}
.dashboard-tab-item-container:has(
+ .dashboard-tab-item-container-active
):before {
right: 16px;
}
.dashboard-tab-item-container-active:before {
content: none;
display: none;
}
.dashboard-tab-item {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 240px;
overflow: visible;
margin: 5px 0 0 0;
padding: 0 10px 0 10px;
border: 1px solid
color-mix(in srgb, var(--color-header-border) 55%, transparent);
border-radius: 12px;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
box-shadow: none;
outline: none;
}
.dashboard-tab-item-body {
display: flex;
align-items: center;
min-width: 0;
}
.dashboard-tab-item-icon {
display: flex;
align-items: center;
min-height: 30px;
line-height: 30px;
margin-right: 6px;
flex-shrink: 0;
}
.dashboard-tab-item:focus-visible {
outline: 2px solid
color-mix(in srgb, var(--color-primary, #1677ff) 45%, transparent);
outline-offset: 2px;
}
.dashboard-tab-item:not(.dashboard-tab-item-active) {
transition:
background 0.3s ease,
border-color 0.3s ease;
}
.dashboard-tab-item:not(.dashboard-tab-item-active):hover {
background: color-mix(in srgb, #ffffff 45%, transparent);
border: 1px solid var(--color-header-border);
}
.dark-mode .dashboard-tab-item:not(.dashboard-tab-item-active):hover {
background: color-mix(in srgb, #ffffff 2.75%, transparent);
}
.dashboard-tab-item-active {
z-index: 2;
align-self: stretch;
margin-bottom: -1px;
padding-bottom: 5px;
border-color: transparent;
position: relative;
border-radius: 12px 12px 0 0;
}
.dashboard-tab-item-active:before {
content: '';
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 21px;
border-radius: 11px 11px 0 0;
z-index: -2;
background: var(--color-layout-background);
}
.dashboard-tab-item-active:after {
content: '';
position: absolute;
top: 14px;
left: 1px;
right: 1px;
bottom: -1px;
z-index: -2;
background: var(--color-layout-background);
}
.dashboard-tab-item-outline-container {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: 0;
}
.dashboard-tab-item-outline {
position: absolute;
inset: 0 0 21px 0;
border: 1px solid var(--color-header-border);
border-bottom: none;
border-radius: 12px 12px 0 0;
pointer-events: none;
opacity: 0;
}
.dashboard-tab-item-corner {
--tab-corner-size: 22px;
--tab-corner-radius: 16px;
--tab-corner-inset: 1px;
--tab-corner-center: calc(
var(--tab-corner-size) - var(--tab-corner-inset) - var(--tab-corner-radius)
);
--tab-fill-nudge-x: 3px;
--tab-fill-nudge-y: 4px;
--tab-fill-circle-x: calc(var(--tab-corner-center) + var(--tab-fill-nudge-x));
--tab-fill-circle-y: calc(var(--tab-corner-size) - var(--tab-corner-radius));
position: absolute;
bottom: -1px;
width: var(--tab-corner-size);
height: var(--tab-corner-size);
overflow: hidden;
pointer-events: none;
opacity: 0;
}
.dashboard-tab-item-corner-left {
left: -21px;
}
.dashboard-tab-item-corner-right {
right: -21px;
}
.dashboard-tab-item-corner-fill {
position: absolute;
width: 26px;
height: 26px;
bottom: calc(-1 * var(--tab-fill-nudge-y));
background: var(--color-layout-background);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
.dashboard-tab-item-corner-left .dashboard-tab-item-corner-fill {
left: calc(-1 * var(--tab-fill-nudge-x));
-webkit-mask-image: radial-gradient(
circle at var(--tab-fill-circle-x) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
mask-image: radial-gradient(
circle at var(--tab-fill-circle-x) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
}
.dashboard-tab-item-corner-right .dashboard-tab-item-corner-fill {
right: calc(-1 * var(--tab-fill-nudge-x));
-webkit-mask-image: radial-gradient(
circle at calc(100% - var(--tab-fill-circle-x)) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
mask-image: radial-gradient(
circle at calc(100% - var(--tab-fill-circle-x)) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
}
.dashboard-tab-item-corner-inner {
position: absolute;
width: 48px;
height: 48px;
box-sizing: border-box;
border-radius: var(--tab-corner-radius);
padding: 1px;
background: var(--color-header-border);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.dashboard-tab-item-corner-left .dashboard-tab-item-corner-inner {
bottom: 0;
right: var(--tab-corner-inset);
}
.dashboard-tab-item-corner-right .dashboard-tab-item-corner-inner {
bottom: 0;
left: var(--tab-corner-inset);
}
.dashboard-tab-item-active .dashboard-tab-item-outline,
.dashboard-tab-item-active .dashboard-tab-item-corner {
opacity: 1;
}
.dashboard-tabs-label {
max-width: 186px;
min-width: 0;
overflow: hidden;
}
.dashboard-tabs-label .elipsis-text-wrapper.ant-typography {
min-width: 0;
max-width: 100%;
}
.dashboard-tab-item-container[data-sticky-placeholder='true']
> .dashboard-tab-item {
visibility: hidden;
pointer-events: none;
}
.dashboard-tabs[data-sticky-edge] .dashboard-tab-item-container:before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-add-button-container:before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::after,
.dashboard-tabs[data-at-start] .dashboard-tabs-wrap::before,
.dashboard-tabs[data-at-end] .dashboard-tabs-wrap::after {
content: none;
display: none;
}
.dashboard-tabs-sticky-line {
display: none;
position: absolute;
bottom: 0;
pointer-events: none;
z-index: 3;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tabs-sticky-line[data-edge] {
display: block;
}
.dashboard-tab-sticky-clone {
position: absolute;
top: 0;
height: 42px;
z-index: 5;
visibility: hidden;
pointer-events: none;
box-sizing: border-box;
flex: none;
overflow: visible;
}
.dashboard-tab-sticky-clone[data-edge] {
visibility: visible;
width: var(--tab-sticky-width);
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item {
pointer-events: none;
box-sizing: border-box;
width: var(--tab-sticky-width);
min-width: var(--tab-sticky-width);
max-width: var(--tab-sticky-width);
justify-content: flex-start;
overflow: visible;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-body {
width: 100%;
overflow: hidden;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-icon {
margin-right: calc(6px * var(--tab-label-opacity, 1));
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label {
flex: 1 1 auto;
min-width: 0;
opacity: var(--tab-label-opacity, 1);
overflow: hidden;
white-space: nowrap;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label .ant-typography {
max-width: 100% !important;
min-width: 0;
overflow: hidden;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-close {
pointer-events: auto;
}
.dashboard-tabs-close {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 1px;
margin-right: -2px;
border-radius: 8px;
color: color-mix(
in srgb,
var(--color-text-secondary, inherit) 70%,
transparent
);
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s ease;
}
.dashboard-tabs-close:hover {
color: var(--color-text-secondary, inherit);
}
.dashboard-tabs-close::before {
content: '';
position: absolute;
inset: -8px -8px -8px -6px;
}
.dashboard-tabs-add-button {
padding-inline: 5px;
min-width: 22px;
margin-top: 5px;
margin-right: 5px;
}
.dashboard-tabs-add-button-container {
flex-grow: 1;
height: 42px;
position: relative;
}
.dashboard-tabs-add-button-container:before {
content: '';
position: absolute;
left: 15px;
right: 0;
bottom: 0;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tab-item-dragging {
opacity: 0.4;
}
.dashboard-tab-item-drop-before::before,
.dashboard-tab-item-drop-after::after {
content: '';
position: absolute;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--color-primary, #1677ff);
pointer-events: none;
}
.dashboard-tab-item-drop-before::before {
left: -5px;
}
.dashboard-tab-item-drop-after::after {
right: -5px;
}
.dashboard-tabs-reordering
.dashboard-tab-item:not(.dashboard-tab-item-dragging) {
cursor: default;
}
.dashboard-tab-panes {
position: relative;
isolation: isolate;
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: hidden;
}
.dashboard-tab-pane {
position: absolute;
top: 10px;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
.dashboard-tab-pane-active {
z-index: 2;
isolation: isolate;
display: flex;
visibility: visible;
opacity: 1;
transform: none;
}
.dashboard-tab-pane-inactive {
z-index: 0;
overflow: hidden;
visibility: hidden;
opacity: 0;
pointer-events: none;
content-visibility: hidden;
}
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
/* Preview render: keep layout, but park the pane off-screen. */
visibility: visible;
opacity: 1;
pointer-events: none;
transform: translate(-100%, 0);
z-index: 0;
content-visibility: visible;
}
.dashboard-tab-preview-capture-frame {
position: absolute;
inset: 0;
z-index: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 24px;
background: var(--tab-preview-render-background);
transform: translate(-100%, 0);
pointer-events: none;
}
.dashboard-tab-preview-capture-frame > .dashboard-tab-pane {
position: relative;
inset: auto;
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 0;
transform: none;
}
.dashboard-tab-preview-capture-frame
> .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
transform: none;
}
.dashboard-tab-preview-popover {
padding-top: 6px;
}
.dashboard-tab-preview-popover .ant-popover-inner {
padding: 0;
overflow: hidden;
background: transparent;
}
.dashboard-tab-preview {
position: relative;
width: 320px;
background: var(--layout-header-bg);
overflow: hidden;
padding: 6px;
border: 1px solid transparent;
border-radius: 12px;
}
.dark-mode.dashboard-tab-preview {
border: 1px solid var(--color-header-border);
}
.dark-mode.dashboard-tab-preview:before {
content: '';
position: absolute;
inset: 1px;
background: var(--tab-preview-render-background);
}
.dashboard-tab-preview-stack {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 7px;
}
.dashboard-tab-preview-svg {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.dashboard-tab-preview-svg-current {
position: relative;
z-index: 0;
}
.dashboard-tab-preview-svg-previous {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
filter: blur(8px);
opacity: 1;
transition:
opacity 280ms ease,
filter 280ms ease;
}
.dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not(
:has(.dashboard-tab-preview-svg-current)
) {
min-height: 160px;
}
.dashboard-tab-preview-svg-previous.is-revealing {
opacity: 0;
filter: blur(0);
}
.dashboard-tab-preview-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.dashboard-tab-preview-loading-icon {
font-size: 16px;
color: var(--color-text-secondary, rgba(255, 255, 255, 0.92));
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.45));
}
.dashboard-tab-preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(128, 128, 128, 0.18);
}
.state-tag-loading svg path { .state-tag-loading svg path {
stroke: currentColor; stroke: currentColor;
stroke-width: 12px; stroke-width: 12px;

546
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"@tsparticles/slim": "^3.9.1", "@tsparticles/slim": "^3.9.1",
"@uiw/react-codemirror": "^4.25.1", "@uiw/react-codemirror": "^4.25.1",
"@vscode/sudo-prompt": "^9.3.2", "@vscode/sudo-prompt": "^9.3.2",
"@zumer/snapdom": "^3.0.0",
"antd": "^5.27.1", "antd": "^5.27.1",
"antd-style": "^3.7.1", "antd-style": "^3.7.1",
"axios": "^1.11.0", "axios": "^1.11.0",
@ -127,9 +128,6 @@
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electrobun": "1.18.1", "electrobun": "1.18.1",
"electron": "^38.7.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",

2157
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@ import AppError from './components/App/AppError'
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx' import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx' import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx' import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { NavigationTabsProvider } from './components/Dashboard/context/NavigationTabsContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx' import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx' import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx' import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
@ -64,26 +65,26 @@ const getRouter = () => {
return BrowserRouter return BrowserRouter
} }
const renderEmpty = () => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
hasBackground={false}
hasBorder={false}
/>
</div>
)
const AppContent = () => { const AppContent = () => {
const { themeConfig } = useThemeContext() const { themeConfig } = useThemeContext()
const Router = getRouter() const Router = getRouter()
return ( return (
<ConfigProvider <ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
theme={themeConfig}
renderEmpty={() => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
hasBackground={false}
hasBorder={false}
/>
</div>
)}
>
<App> <App>
<Router> <Router>
<ElectronProvider> <ElectronProvider>
<NavigationTabsProvider>
<AuthProvider> <AuthProvider>
<PrintServerProvider> <PrintServerProvider>
<ApiServerProvider> <ApiServerProvider>
@ -183,6 +184,7 @@ const AppContent = () => {
</ApiServerProvider> </ApiServerProvider>
</PrintServerProvider> </PrintServerProvider>
</AuthProvider> </AuthProvider>
</NavigationTabsProvider>
</ElectronProvider> </ElectronProvider>
</Router> </Router>
</App> </App>

View File

@ -22,22 +22,22 @@ const {
const { const {
createMainWindow, createMainWindow,
handleDeepLinkFromArgv, handleDeepLinkFromArgv,
persistSessionNow,
setupDevAuthServer, setupDevAuthServer,
setupNavigationGestures,
setupWindowsDeepLinkHandling setupWindowsDeepLinkHandling
} = await import('../desktop/window.js') } = await import('../desktop/window.js')
setupWindowsDeepLinkHandling() setupWindowsDeepLinkHandling()
const rpc = createAppRpc() const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc) await createMainWindow(rpc)
setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc) registerGlobalShortcuts(rpc)
setupDevAuthServer() setupDevAuthServer()
handleDeepLinkFromArgv(launchUrl) handleDeepLinkFromArgv(launchUrl)
process.on('exit', () => { process.on('exit', () => {
persistSessionNow()
unregisterGlobalShortcuts() unregisterGlobalShortcuts()
closeSingleInstanceServer() closeSingleInstanceServer()
}) })

View File

@ -1,13 +1,12 @@
// Dashboard.js
import Layout from './Layout' import Layout from './Layout'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { useNavigationTabs } from './context/NavigationTabsContext'
import DashboardTabPanes from './common/DashboardTabPanes'
const Dashboard = () => { const Dashboard = () => {
return ( const { isElectron } = useNavigationTabs()
<Layout>
<Outlet /> return <Layout>{isElectron ? <DashboardTabPanes /> : <Outlet />}</Layout>
</Layout>
)
} }
export default Dashboard export default Dashboard

View File

@ -27,7 +27,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import OrderItemIcon from '../../../Icons/OrderItemIcon.jsx' import OrderItemIcon from '../../../Icons/OrderItemIcon.jsx'
import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx' import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx'
@ -91,7 +94,7 @@ const InvoiceInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -100,6 +103,7 @@ const InvoiceInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -257,7 +261,9 @@ const InvoiceInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='payment' type='payment'
masterFilter={{ invoice: getModelByName('invoice').prefix + ':' + invoiceId }} masterFilter={{
invoice: getModelByName('invoice').prefix + ':' + invoiceId
}}
visibleColumns={{ invoice: false }} visibleColumns={{ invoice: false }}
/> />
)} )}
@ -274,7 +280,7 @@ const InvoiceInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={invoiceId} type='invoice' /> <NotesPanel _id={invoiceId} type='invoice' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -291,10 +297,7 @@ const InvoiceInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('invoice').prefix + ':' + invoiceId
getModelByName('invoice').prefix +
':' +
invoiceId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -93,7 +93,7 @@ const PaymentPolicyInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -84,7 +84,7 @@ const PaymentInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -93,6 +93,7 @@ const PaymentInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -209,7 +210,7 @@ const PaymentInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={paymentId} type='payment' /> <NotesPanel _id={paymentId} type='payment' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>

View File

@ -77,7 +77,7 @@ const TaxRecordInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -85,14 +85,14 @@ const TaxRecordInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='taxRecord' type='taxRecord'
pageName='info' pageName='info'
id={taxRecordId} id={taxRecordId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -193,7 +193,7 @@ const TaxRecordInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={taxRecordId} type='taxRecord' /> <NotesPanel _id={taxRecordId} type='taxRecord' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -212,10 +212,8 @@ const TaxRecordInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('taxRecord').prefix + getModelByName('taxRecord').prefix + ':' + taxRecordId
':' +
taxRecordId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -87,14 +87,14 @@ const FilamentStockInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='filamentStock' type='filamentStock'
pageName='info' pageName='info'
id={filamentStockId} id={filamentStockId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -197,7 +197,12 @@ const FilamentStockInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ parent: getModelByName('filamentStock').prefix + ':' + filamentStockId }} masterFilter={{
parent:
getModelByName('filamentStock').prefix +
':' +
filamentStockId
}}
visibleColumns={{ parent: false }} visibleColumns={{ parent: false }}
/> />
)} )}
@ -232,7 +237,7 @@ const FilamentStockInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={filamentStockId} type='filamentStock' /> <NotesPanel _id={filamentStockId} type='filamentStock' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -251,10 +256,10 @@ const FilamentStockInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('filamentStock').prefix + getModelByName('filamentStock').prefix +
':' + ':' +
filamentStockId filamentStockId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -78,7 +78,7 @@ const OrderItemInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -88,6 +88,7 @@ const OrderItemInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -96,7 +97,7 @@ const OrderItemInfo = () => {
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='orderItem' type='orderItem'
pageName='info' pageName='info'
id={orderItemId} id={orderItemId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -204,7 +205,7 @@ const OrderItemInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={orderItemId} type='orderItem' /> <NotesPanel _id={orderItemId} type='orderItem' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -223,10 +224,8 @@ const OrderItemInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('orderItem').prefix + getModelByName('orderItem').prefix + ':' + orderItemId
':' +
orderItemId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -93,6 +93,7 @@ const PartStockInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -101,7 +102,7 @@ const PartStockInfo = () => {
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='partStock' type='partStock'
pageName='info' pageName='info'
id={partStockId} id={partStockId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -214,7 +215,10 @@ const PartStockInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ parent: getModelByName('partStock').prefix + ':' + partStockId }} masterFilter={{
parent:
getModelByName('partStock').prefix + ':' + partStockId
}}
visibleColumns={{ parent: false }} visibleColumns={{ parent: false }}
/> />
)} )}
@ -251,7 +255,7 @@ const PartStockInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={partStockId} type='partStock' /> <NotesPanel _id={partStockId} type='partStock' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -271,10 +275,8 @@ const PartStockInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('partStock').prefix + getModelByName('partStock').prefix + ':' + partStockId
':' +
partStockId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -109,6 +109,7 @@ const ProductStockInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >

View File

@ -102,7 +102,7 @@ const PurchaseOrderInfo = () => {
orderItemsTableRef?.current?.handleUpdate?.() orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -111,6 +111,7 @@ const PurchaseOrderInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -230,7 +231,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='orderItem' type='orderItem'
masterFilter={{ masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId, order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder' orderType: 'purchaseOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -249,7 +253,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='shipment' type='shipment'
masterFilter={{ masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId, order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder' orderType: 'purchaseOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -271,7 +278,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='invoice' type='invoice'
masterFilter={{ masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId, order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder' orderType: 'purchaseOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -293,7 +303,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ masterFilter={{
owner: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId owner:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId
}} }}
/> />
)} )}
@ -314,7 +327,7 @@ const PurchaseOrderInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' /> <NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -331,10 +344,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('purchaseOrder').prefix + getModelByName('purchaseOrder').prefix +
':' + ':' +
purchaseOrderId purchaseOrderId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -121,6 +121,7 @@ const PurchaseOrderInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -129,7 +130,7 @@ const PurchaseOrderInfo = () => {
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='purchaseOrder' type='purchaseOrder'
pageName='info' pageName='info'
id={purchaseOrderId} id={purchaseOrderId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -233,7 +234,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='orderItem' type='orderItem'
masterFilter={{ masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId, order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder' orderType: 'purchaseOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -252,7 +256,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='shipment' type='shipment'
masterFilter={{ masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId, order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder' orderType: 'purchaseOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -275,7 +282,7 @@ const PurchaseOrderInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' /> <NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -294,10 +301,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('purchaseOrder').prefix + getModelByName('purchaseOrder').prefix +
':' + ':' +
purchaseOrderId purchaseOrderId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -96,6 +96,7 @@ const ShipmentInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >

View File

@ -26,7 +26,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('StockAuditInfo') const log = loglevel.getLogger('StockAuditInfo')
@ -92,6 +95,7 @@ const StockAuditInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -180,7 +184,9 @@ const StockAuditInfo = () => {
title='Stock Audit Information' title='Stock Audit Information'
icon={<InfoCircleIcon />} icon={<InfoCircleIcon />}
active={collapseState.info} active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)} onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info' collapseKey='info'
> >
<ObjectInfo <ObjectInfo
@ -262,9 +268,7 @@ const StockAuditInfo = () => {
title='Audit Logs' title='Audit Logs'
icon={<AuditLogIcon />} icon={<AuditLogIcon />}
active={collapseState.auditLogs} active={collapseState.auditLogs}
onToggle={(expanded) => onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs' collapseKey='auditLogs'
> >
{objectFormState.loading ? ( {objectFormState.loading ? (
@ -274,9 +278,7 @@ const StockAuditInfo = () => {
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('stockAudit').prefix + getModelByName('stockAudit').prefix + ':' + stockAuditId
':' +
stockAuditId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -98,6 +98,7 @@ const StockLocationInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -106,7 +107,7 @@ const StockLocationInfo = () => {
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='stockLocation' type='stockLocation'
pageName='info' pageName='info'
id={stockLocationId} id={stockLocationId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -329,7 +330,7 @@ const StockLocationInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={stockLocationId} type='stockLocation' /> <NotesPanel _id={stockLocationId} type='stockLocation' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -349,10 +350,10 @@ const StockLocationInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('stockLocation').prefix + getModelByName('stockLocation').prefix +
':' + ':' +
stockLocationId stockLocationId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -8,7 +8,10 @@ import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx' import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx' import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx' import ViewButton from '../../common/ViewButton.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
@ -89,7 +92,7 @@ const StockTransferInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current.handleUpdate() objectFormRef?.current.handleUpdate()
return true return true
}, }
} }
return ( return (
@ -98,6 +101,7 @@ const StockTransferInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -263,7 +267,7 @@ const StockTransferInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={stockTransferId} type='stockTransfer' /> <NotesPanel _id={stockTransferId} type='stockTransfer' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -281,10 +285,10 @@ const StockTransferInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('stockTransfer').prefix + getModelByName('stockTransfer').prefix +
':' + ':' +
stockTransferId stockTransferId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -1,6 +1,7 @@
// DashboardLayout.js // DashboardLayout.js
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Layout, Flex } from 'antd' import { Layout, Flex } from 'antd'
import { useContext } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import ProductionSidebar from './Production/ProductionSidebar' import ProductionSidebar from './Production/ProductionSidebar'
import InventorySidebar from './Inventory/InventorySidebar' import InventorySidebar from './Inventory/InventorySidebar'
@ -13,15 +14,16 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter' import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation' import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext' import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext' import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './context/ElectronContext'
const { Content } = Layout const { Content } = Layout
const DashboardLayout = ({ children }) => { const DashboardLayout = ({ children }) => {
const location = useLocation() const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const { isElectron } = useContext(ElectronContext)
const isProduction = location.pathname.startsWith('/dashboard/production') const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory') const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isFinance = location.pathname.startsWith('/dashboard/finance') const isFinance = location.pathname.startsWith('/dashboard/finance')
@ -49,31 +51,33 @@ const DashboardLayout = ({ children }) => {
) )
return ( return (
<MessageProvider> <Layout
<Layout style={{ height: 'var(--unit-100vh)' }}
style={{ height: 'var(--unit-100vh)' }} className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`} >
> <DashboardNavigation />
<DashboardNavigation /> <DashboardSidebarSplitter sidebar={sidebar}>
<DashboardSidebarSplitter sidebar={sidebar}> <Layout
<Layout style={{ padding: '24px', height: '100%' }}
style={{ padding: '24px', height: '100%' }} className='main-content-layout'
className='main-content-layout' >
> <Content>
<Content> <Flex
<Flex vertical style={{ height: '100%' }} gap='20px'> vertical
<Flex justify='space-between' align='center'> style={{ height: '100%' }}
<DashboardBreadcrumb style={{ margin: '16px 0' }} /> gap={isElectron ? '10px' : '20px'}
{currentObjectTools} >
</Flex> <Flex justify='space-between' align='center'>
{children} <DashboardBreadcrumb style={{ margin: '16px 0' }} />
{currentObjectTools}
</Flex> </Flex>
</Content> <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</Layout> </Flex>
</DashboardSidebarSplitter> </Content>
{isMobile ? <MobileFooterNavigation /> : null} </Layout>
</Layout> </DashboardSidebarSplitter>
</MessageProvider> {isMobile ? <MobileFooterNavigation /> : null}
</Layout>
) )
} }

View File

@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive'
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon' import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon' import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
import ExternalLink from '../common/ExternalLink' import ExternalLink from '../common/ExternalLink'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const { Title, Text } = Typography const { Title, Text } = Typography
const About = () => { const About = () => {
useNavigationTabPage({ title: 'About', iconKey: 'infoCircle' })
const [collapseState, updateCollapseState] = useCollapseState('About', { const [collapseState, updateCollapseState] = useCollapseState('About', {
updater: true updater: true
}) })

View File

@ -87,7 +87,7 @@ const AppPasswordInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -196,10 +196,8 @@ const AppPasswordInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('appPassword').prefix + getModelByName('appPassword').prefix + ':' + appPasswordId
':' +
appPasswordId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('CourierServiceInfo') const log = loglevel.getLogger('CourierServiceInfo')
@ -82,7 +85,7 @@ const CourierServiceInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -90,14 +93,14 @@ const CourierServiceInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='courierService' type='courierService'
pageName='info' pageName='info'
id={courierServiceId} id={courierServiceId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -222,7 +225,7 @@ const CourierServiceInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={courierServiceId} type='courierService' /> <NotesPanel _id={courierServiceId} type='courierService' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -241,10 +244,10 @@ const CourierServiceInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('courierService').prefix + getModelByName('courierService').prefix +
':' + ':' +
courierServiceId courierServiceId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -75,7 +75,7 @@ const CourierInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -83,14 +83,14 @@ const CourierInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='courier' type='courier'
pageName='info' pageName='info'
id={courierId} id={courierId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -193,7 +193,9 @@ const CourierInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='courierService' type='courierService'
masterFilter={{ courier: getModelByName('courier').prefix + ':' + courierId }} masterFilter={{
courier: getModelByName('courier').prefix + ':' + courierId
}}
visibleColumns={{ visibleColumns={{
courier: false courier: false
}} }}
@ -212,7 +214,7 @@ const CourierInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={courierId} type='courier' /> <NotesPanel _id={courierId} type='courier' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -231,10 +233,7 @@ const CourierInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('courier').prefix + ':' + courierId
getModelByName('courier').prefix +
':' +
courierId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -87,14 +87,14 @@ const DocumentJobInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='documentJob' type='documentJob'
pageName='info' pageName='info'
id={documentJobId} id={documentJobId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -196,7 +196,7 @@ const DocumentJobInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={documentJobId} type='documentJob' /> <NotesPanel _id={documentJobId} type='documentJob' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -215,10 +215,8 @@ const DocumentJobInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('documentJob').prefix + getModelByName('documentJob').prefix + ':' + documentJobId
':' +
documentJobId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -91,6 +91,7 @@ const DocumentPrinterInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >

View File

@ -87,14 +87,14 @@ const DocumentSizeInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='documentSize' type='documentSize'
pageName='info' pageName='info'
id={documentSizeId} id={documentSizeId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -197,7 +197,7 @@ const DocumentSizeInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={documentSizeId} type='documentSize' /> <NotesPanel _id={documentSizeId} type='documentSize' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -216,10 +216,10 @@ const DocumentSizeInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('documentSize').prefix + getModelByName('documentSize').prefix +
':' + ':' +
documentSizeId documentSizeId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -19,6 +19,7 @@ import { useDashboardObjectTools } from '../../context/DashboardObjectToolsConte
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import TemplateEditor from '../../common/TemplateEditor.jsx' import TemplateEditor from '../../common/TemplateEditor.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('DocumentTemplateDesign') const log = loglevel.getLogger('DocumentTemplateDesign')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -30,6 +31,11 @@ const DocumentTemplateDesign = () => {
const documentTemplateId = new URLSearchParams(location.search).get( const documentTemplateId = new URLSearchParams(location.search).get(
'documentTemplateId' 'documentTemplateId'
) )
useObjectNavigationTabPage({
modelName: 'documentTemplate',
pageName: 'design',
objectId: documentTemplateId
})
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'DocumentTemplateDesign', 'DocumentTemplateDesign',
{ {

View File

@ -97,6 +97,7 @@ const DocumentTemplateInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >

View File

@ -88,7 +88,7 @@ const EmailAccountInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js' import {
getModelByName,
getModelProperty
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import TemplatePreview from '../../common/TemplatePreview.jsx' import TemplatePreview from '../../common/TemplatePreview.jsx'
@ -74,7 +77,7 @@ const EmailMessageInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -16,6 +16,7 @@ import ObjectActions from '../../common/ObjectActions'
import TemplateEditor from '../../common/TemplateEditor' import TemplateEditor from '../../common/TemplateEditor'
import ScrollBox from '../../common/ScrollBox' import ScrollBox from '../../common/ScrollBox'
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext' import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const EmailTemplateDesign = () => { const EmailTemplateDesign = () => {
const location = useLocation() const location = useLocation()
@ -24,6 +25,11 @@ const EmailTemplateDesign = () => {
const emailTemplateId = new URLSearchParams(location.search).get( const emailTemplateId = new URLSearchParams(location.search).get(
'emailTemplateId' 'emailTemplateId'
) )
useObjectNavigationTabPage({
modelName: 'emailTemplate',
pageName: 'design',
objectId: emailTemplateId
})
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'EmailTemplateDesign', 'EmailTemplateDesign',
{ preview: true, editor: true, notes: true } { preview: true, editor: true, notes: true }

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js' import {
getModelByName,
getModelProperty
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('EmailTemplateInfo') const log = loglevel.getLogger('EmailTemplateInfo')
@ -92,7 +95,7 @@ const EmailTemplateInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -74,7 +74,7 @@ const FilamentSkuInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -82,14 +82,14 @@ const FilamentSkuInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='filamentSku' type='filamentSku'
pageName='info' pageName='info'
id={filamentSkuId} id={filamentSkuId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -190,7 +190,7 @@ const FilamentSkuInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={filamentSkuId} type='filamentSku' /> <NotesPanel _id={filamentSkuId} type='filamentSku' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -209,10 +209,8 @@ const FilamentSkuInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('filamentSku').prefix + getModelByName('filamentSku').prefix + ':' + filamentSkuId
':' +
filamentSkuId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -92,7 +92,7 @@ const FilamentInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -101,6 +101,7 @@ const FilamentInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -220,7 +221,9 @@ const FilamentInfo = () => {
<ObjectTable <ObjectTable
ref={filamentSkusTableRef} ref={filamentSkusTableRef}
type='filamentSku' type='filamentSku'
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }} masterFilter={{
filament: getModelByName('filament').prefix + ':' + filamentId
}}
visibleColumns={{ filament: false }} visibleColumns={{ filament: false }}
/> />
)} )}
@ -238,7 +241,9 @@ const FilamentInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='filamentStock' type='filamentStock'
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }} masterFilter={{
filament: getModelByName('filament').prefix + ':' + filamentId
}}
visibleColumns={{ visibleColumns={{
filament: false, filament: false,
startingWeight: false startingWeight: false
@ -259,7 +264,7 @@ const FilamentInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={filamentId} type='filament' /> <NotesPanel _id={filamentId} type='filament' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -277,10 +282,7 @@ const FilamentInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('filament').prefix + ':' + filamentId
getModelByName('filament').prefix +
':' +
filamentId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -98,7 +98,7 @@ const FileInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -87,7 +87,7 @@ const HostInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current.handleUpdate() objectFormRef?.current.handleUpdate()
return true return true
}, }
} }
return ( return (
@ -96,6 +96,7 @@ const HostInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -213,7 +214,9 @@ const HostInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='printer' type='printer'
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }} masterFilter={{
host: getModelByName('host').prefix + ':' + hostId
}}
visibleColumns={{ visibleColumns={{
host: false host: false
}} }}
@ -234,7 +237,9 @@ const HostInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='documentPrinter' type='documentPrinter'
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }} masterFilter={{
host: getModelByName('host').prefix + ':' + hostId
}}
visibleColumns={{ visibleColumns={{
host: false host: false
}} }}
@ -254,7 +259,7 @@ const HostInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={hostId} type='host' /> <NotesPanel _id={hostId} type='host' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -272,10 +277,7 @@ const HostInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('host').prefix + ':' + hostId
getModelByName('host').prefix +
':' +
hostId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -77,7 +77,7 @@ const MaterialInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -85,14 +85,14 @@ const MaterialInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='material' type='material'
pageName='info' pageName='info'
id={materialId} id={materialId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -193,7 +193,7 @@ const MaterialInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={materialId} type='material' /> <NotesPanel _id={materialId} type='material' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -212,10 +212,7 @@ const MaterialInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('material').prefix + ':' + materialId
getModelByName('material').prefix +
':' +
materialId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -77,14 +77,14 @@ const NoteTypeInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='noteType' type='noteType'
pageName='info' pageName='info'
id={noteTypeId} id={noteTypeId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -188,10 +188,7 @@ const NoteTypeInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('noteType').prefix + ':' + noteTypeId
getModelByName('noteType').prefix +
':' +
noteTypeId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -21,12 +21,16 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx' import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext' import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
const log = loglevel.getLogger('NoteInfo') const log = loglevel.getLogger('NoteInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -36,6 +40,11 @@ const NoteInfo = () => {
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)
const noteId = new URLSearchParams(location.search).get('noteId') const noteId = new URLSearchParams(location.search).get('noteId')
useObjectNavigationTabPage({
modelName: 'note',
pageName: 'info',
objectId: noteId
})
const [collapseState, updateCollapseState] = useCollapseState('NoteInfo', { const [collapseState, updateCollapseState] = useCollapseState('NoteInfo', {
info: true, info: true,
content: true, content: true,
@ -77,7 +86,7 @@ const NoteInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -85,14 +94,14 @@ const NoteInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='note' type='note'
pageName='info' pageName='info'
id={noteId} id={noteId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -220,7 +229,7 @@ const NoteInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={noteId} type='note' /> <NotesPanel _id={noteId} type='note' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -239,10 +248,7 @@ const NoteInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('note').prefix + ':' + noteId
getModelByName('note').prefix +
':' +
noteId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -69,7 +69,7 @@ const PartSkuInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -77,14 +77,14 @@ const PartSkuInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='partSku' type='partSku'
pageName='info' pageName='info'
id={partSkuId} id={partSkuId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -185,7 +185,7 @@ const PartSkuInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={partSkuId} type='partSku' /> <NotesPanel _id={partSkuId} type='partSku' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -204,10 +204,7 @@ const PartSkuInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('partSku').prefix + ':' + partSkuId
getModelByName('partSku').prefix +
':' +
partSkuId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -88,7 +88,7 @@ const PartInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -197,7 +197,9 @@ const PartInfo = () => {
<ObjectTable <ObjectTable
ref={partSkusTableRef} ref={partSkusTableRef}
type='partSku' type='partSku'
masterFilter={{ part: getModelByName('part').prefix + ':' + partId }} masterFilter={{
part: getModelByName('part').prefix + ':' + partId
}}
visibleColumns={{ part: false }} visibleColumns={{ part: false }}
/> />
)} )}
@ -215,7 +217,7 @@ const PartInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={partId} type='part' /> <NotesPanel _id={partId} type='part' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -232,10 +234,7 @@ const PartInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('part').prefix + ':' + partId
getModelByName('part').prefix +
':' +
partId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -80,7 +80,7 @@ const PermissionSettingInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -88,14 +88,14 @@ const PermissionSettingInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='permissionSetting' type='permissionSetting'
pageName='info' pageName='info'
id={permissionSettingId} id={permissionSettingId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -227,10 +227,10 @@ const PermissionSettingInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel <NotesPanel
_id={permissionSettingId} _id={permissionSettingId}
type='permissionSetting' type='permissionSetting'
/> />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>

View File

@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const ProductCategoryInfo = () => { const ProductCategoryInfo = () => {
@ -77,7 +80,7 @@ const ProductCategoryInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -85,14 +88,14 @@ const ProductCategoryInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='productCategory' type='productCategory'
pageName='info' pageName='info'
id={productCategoryId} id={productCategoryId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -217,7 +220,7 @@ const ProductCategoryInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={productCategoryId} type='productCategory' /> <NotesPanel _id={productCategoryId} type='productCategory' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -236,10 +239,10 @@ const ProductCategoryInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('productCategory').prefix + getModelByName('productCategory').prefix +
':' + ':' +
productCategoryId productCategoryId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -23,7 +23,10 @@ import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx' import ObjectProperty from '../../common/ObjectProperty.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import PartIcon from '../../../Icons/PartIcon.jsx' import PartIcon from '../../../Icons/PartIcon.jsx'
const ProductSkuInfo = () => { const ProductSkuInfo = () => {
@ -75,7 +78,7 @@ const ProductSkuInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -83,14 +86,14 @@ const ProductSkuInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='productSku' type='productSku'
pageName='info' pageName='info'
id={productSkuId} id={productSkuId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -217,7 +220,7 @@ const ProductSkuInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={productSkuId} type='productSku' /> <NotesPanel _id={productSkuId} type='productSku' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -236,10 +239,8 @@ const ProductSkuInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('productSku').prefix + getModelByName('productSku').prefix + ':' + productSkuId
':' +
productSkuId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -88,7 +88,7 @@ const ProductInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -200,7 +200,10 @@ const ProductInfo = () => {
<ObjectTable <ObjectTable
ref={productSkusTableRef} ref={productSkusTableRef}
type='productSku' type='productSku'
masterFilter={{ product: getModelByName('product').prefix + ':' + productId }} masterFilter={{
product:
getModelByName('product').prefix + ':' + productId
}}
visibleColumns={{ product: false }} visibleColumns={{ product: false }}
/> />
)} )}
@ -222,7 +225,7 @@ const ProductInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={productId} type='product' /> <NotesPanel _id={productId} type='product' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -239,10 +242,7 @@ const ProductInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('product').prefix + ':' + productId
getModelByName('product').prefix +
':' +
productId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -23,6 +23,7 @@ import settingsSchema, {
getVisibleSettingsSections, getVisibleSettingsSections,
pickSettings pickSettings
} from '../../../database/Settings' } from '../../../database/Settings'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native' const DEFAULT_UPDATE_ENGINE = 'native'
@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [
] ]
const Settings = () => { const Settings = () => {
useNavigationTabPage({ title: 'Settings', iconKey: 'settings' })
const { const {
isDarkMode, isDarkMode,
isCompact, isCompact,

View File

@ -22,7 +22,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const StockAuditLevelInfo = () => { const StockAuditLevelInfo = () => {
@ -82,7 +85,7 @@ const StockAuditLevelInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -232,9 +235,7 @@ const StockAuditLevelInfo = () => {
title='Audit Logs' title='Audit Logs'
icon={<AuditLogIcon />} icon={<AuditLogIcon />}
active={collapseState.auditLogs} active={collapseState.auditLogs}
onToggle={(expanded) => onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs' collapseKey='auditLogs'
> >
{objectFormState.loading ? ( {objectFormState.loading ? (

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('TaxRateInfo') const log = loglevel.getLogger('TaxRateInfo')
@ -77,7 +80,7 @@ const TaxRateInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -85,14 +88,14 @@ const TaxRateInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='taxRate' type='taxRate'
pageName='info' pageName='info'
id={taxRateId} id={taxRateId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -217,7 +220,7 @@ const TaxRateInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={taxRateId} type='taxRate' /> <NotesPanel _id={taxRateId} type='taxRate' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -236,10 +239,7 @@ const TaxRateInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('taxRate').prefix + ':' + taxRateId
getModelByName('taxRate').prefix +
':' +
taxRateId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -80,14 +80,14 @@ const UserGroupInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -243,7 +243,7 @@ const UserGroupInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={userGroupId} type='userGroup' /> <NotesPanel _id={userGroupId} type='userGroup' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>

View File

@ -86,7 +86,7 @@ const UserInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -264,7 +264,7 @@ const UserInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={userId} type='user' /> <NotesPanel _id={userId} type='user' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>

View File

@ -74,7 +74,7 @@ const VendorInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -82,14 +82,14 @@ const VendorInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='vendor' type='vendor'
pageName='info' pageName='info'
id={vendorId} id={vendorId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -190,7 +190,7 @@ const VendorInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={vendorId} type='vendor' /> <NotesPanel _id={vendorId} type='vendor' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -209,10 +209,7 @@ const VendorInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('vendor').prefix + ':' + vendorId
getModelByName('vendor').prefix +
':' +
vendorId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -111,14 +111,14 @@ const FilamentProfileInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef.current?.handleUpdate?.() objectFormRef.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify='space-between'> <Flex justify='space-between'>
<Space size='small'> <Space size='small'>
@ -529,7 +529,7 @@ const FilamentProfileInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={filamentProfileId} type='filamentProfile' /> <NotesPanel _id={filamentProfileId} type='filamentProfile' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -547,10 +547,10 @@ const FilamentProfileInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('filamentProfile').prefix + getModelByName('filamentProfile').prefix +
':' + ':' +
filamentProfileId filamentProfileId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -25,7 +25,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import PartIcon from '../../../Icons/PartIcon.jsx' import PartIcon from '../../../Icons/PartIcon.jsx'
const log = loglevel.getLogger('GCodeFileInfo') const log = loglevel.getLogger('GCodeFileInfo')
@ -91,6 +94,7 @@ const GCodeFileInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -99,7 +103,7 @@ const GCodeFileInfo = () => {
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='gcodeFile' type='gcodeFile'
pageName='info' pageName='info'
id={gcodeFileId} id={gcodeFileId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -230,7 +234,7 @@ const GCodeFileInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={gcodeFileId} type='gcodeFile' /> <NotesPanel _id={gcodeFileId} type='gcodeFile' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -250,10 +254,8 @@ const GCodeFileInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('gcodeFile').prefix + getModelByName('gcodeFile').prefix + ':' + gcodeFileId
':' +
gcodeFileId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -21,6 +21,7 @@ import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('GCodeFilePreview') const log = loglevel.getLogger('GCodeFilePreview')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -29,6 +30,11 @@ const GCodeFilePreview = () => {
const location = useLocation() const location = useLocation()
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId') const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
useObjectNavigationTabPage({
modelName: 'gcodeFile',
pageName: 'preview',
objectId: gcodeFileId
})
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFilePreview', 'GCodeFilePreview',
{ {

View File

@ -97,6 +97,7 @@ const JobInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -224,7 +225,7 @@ const JobInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={jobId} type='job' /> <NotesPanel _id={jobId} type='job' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -242,10 +243,7 @@ const JobInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('job').prefix + ':' + jobId
getModelByName('job').prefix +
':' +
jobId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -27,7 +27,10 @@ import UserNotifierToggle from '../../common/UserNotifierToggle'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import ObjectProperty from '../../common/ObjectProperty' import ObjectProperty from '../../common/ObjectProperty'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon' import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import SettingsIcon from '../../../Icons/SettingsIcon' import SettingsIcon from '../../../Icons/SettingsIcon'
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
@ -89,14 +92,14 @@ const PrinterProfileInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef.current?.handleUpdate?.() objectFormRef.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify='space-between'> <Flex justify='space-between'>
<Space size='small'> <Space size='small'>
@ -380,7 +383,7 @@ const PrinterProfileInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={printerProfileId} type='printerProfile' /> <NotesPanel _id={printerProfileId} type='printerProfile' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -398,10 +401,10 @@ const PrinterProfileInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('printerProfile').prefix + getModelByName('printerProfile').prefix +
':' + ':' +
printerProfileId printerProfileId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -37,6 +37,7 @@ import ObjectTableNavigationButtons from '../../common/ObjectTableNavigationButt
import PrinterControlButtons from '../../common/PrinterControlButtons.jsx' import PrinterControlButtons from '../../common/PrinterControlButtons.jsx'
import PrinterQueue from '../../common/PrinterQueue.jsx' import PrinterQueue from '../../common/PrinterQueue.jsx'
import ListIcon from '../../../Icons/ListIcon.jsx' import ListIcon from '../../../Icons/ListIcon.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('ControlPrinter') const log = loglevel.getLogger('ControlPrinter')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -47,6 +48,11 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const printerId = new URLSearchParams(location.search).get('printerId') const printerId = new URLSearchParams(location.search).get('printerId')
useObjectNavigationTabPage({
modelName: 'printer',
pageName: 'control',
objectId: printerId
})
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'ControlPrinter', 'ControlPrinter',
{ {

View File

@ -98,6 +98,7 @@ const PrinterInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -223,7 +224,9 @@ const PrinterInfo = () => {
<ObjectTable <ObjectTable
ref={profilesTableRef} ref={profilesTableRef}
type='printerProfile' type='printerProfile'
masterFilter={{ printer: getModelByName('printer').prefix + ':' + printerId }} masterFilter={{
printer: getModelByName('printer').prefix + ':' + printerId
}}
visibleColumns={{ visibleColumns={{
printer: false, printer: false,
'printer._id': false 'printer._id': false
@ -244,7 +247,7 @@ const PrinterInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={printerId} type='printer' /> <NotesPanel _id={printerId} type='printer' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -262,10 +265,7 @@ const PrinterInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('printer').prefix + ':' + printerId
getModelByName('printer').prefix +
':' +
printerId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -77,14 +77,14 @@ const SubJobInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='subJob' type='subJob'
pageName='info' pageName='info'
id={subJobId} id={subJobId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -183,7 +183,9 @@ const SubJobInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ owner: getModelByName('subJob').prefix + ':' + subJobId }} masterFilter={{
owner: getModelByName('subJob').prefix + ':' + subJobId
}}
visibleColumns={{ visibleColumns={{
owner: false, owner: false,
'owner.type': false 'owner.type': false
@ -203,7 +205,7 @@ const SubJobInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={subJobId} type='subJob' /> <NotesPanel _id={subJobId} type='subJob' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -222,10 +224,7 @@ const SubJobInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('subJob').prefix + ':' + subJobId
getModelByName('subJob').prefix +
':' +
subJobId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -74,7 +74,7 @@ const ClientInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -82,14 +82,14 @@ const ClientInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Space size='small'> <Space size='small'>
<ObjectActions <ObjectActions
type='client' type='client'
pageName='info' pageName='info'
id={clientId} id={clientId}
disabled={objectFormState.loading} disabled={objectFormState.loading}
objectData={objectFormState.objectData} objectData={objectFormState.objectData}
@ -190,7 +190,7 @@ const ClientInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={clientId} type='client' /> <NotesPanel _id={clientId} type='client' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -209,10 +209,7 @@ const ClientInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent: getModelByName('client').prefix + ':' + clientId
getModelByName('client').prefix +
':' +
clientId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -93,7 +93,7 @@ const FulfillmentPolicyInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -96,14 +96,14 @@ const ListingVarientInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
@ -256,7 +256,7 @@ const ListingVarientInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={listingVarientId} type='listingVarient' /> <NotesPanel _id={listingVarientId} type='listingVarient' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>

View File

@ -101,7 +101,7 @@ const ListingInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -162,7 +162,7 @@ const MarketplaceInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -93,7 +93,7 @@ const ReturnPolicyInfo = () => {
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }} style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
> >
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>

View File

@ -100,7 +100,7 @@ const SalesOrderInfo = () => {
orderItemsTableRef?.current?.handleUpdate?.() orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, }
} }
return ( return (
@ -109,6 +109,7 @@ const SalesOrderInfo = () => {
vertical='true' vertical='true'
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
height: '100%',
minHeight: 0 minHeight: 0
}} }}
> >
@ -228,7 +229,10 @@ const SalesOrderInfo = () => {
<ObjectTable <ObjectTable
type='orderItem' type='orderItem'
masterFilter={{ masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId, order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder' orderType: 'salesOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -247,7 +251,10 @@ const SalesOrderInfo = () => {
<ObjectTable <ObjectTable
type='shipment' type='shipment'
masterFilter={{ masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId, order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder' orderType: 'salesOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -269,7 +276,10 @@ const SalesOrderInfo = () => {
<ObjectTable <ObjectTable
type='invoice' type='invoice'
masterFilter={{ masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId, order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder' orderType: 'salesOrder'
}} }}
visibleColumns={{ order: false }} visibleColumns={{ order: false }}
@ -291,7 +301,10 @@ const SalesOrderInfo = () => {
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ masterFilter={{
owner: getModelByName('salesOrder').prefix + ':' + salesOrderId owner:
getModelByName('salesOrder').prefix +
':' +
salesOrderId
}} }}
/> />
)} )}
@ -312,7 +325,7 @@ const SalesOrderInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={salesOrderId} type='salesOrder' /> <NotesPanel _id={salesOrderId} type='salesOrder' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -329,10 +342,8 @@ const SalesOrderInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('salesOrder').prefix + getModelByName('salesOrder').prefix + ':' + salesOrderId
':' +
salesOrderId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -1,11 +1,14 @@
// DashboardBreadcrumb.js // DashboardBreadcrumb.js
import { useContext } from 'react'
import { Breadcrumb, Button, Flex, Space } from 'antd' import { Breadcrumb, Button, Flex, Space } from 'antd'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon' import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
import ArrowRightIcon from '../../Icons/ArrowRightIcon' import ArrowRightIcon from '../../Icons/ArrowRightIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import { getModelByPluralName } from '../../../database/ObjectModels' import { getModelByPluralName } from '../../../database/ObjectModels'
import { ElectronContext } from '../context/ElectronContext'
import { useTableState } from '../context/TableStateContext' import { useTableState } from '../context/TableStateContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
const breadcrumbNameMap = { const breadcrumbNameMap = {
production: 'Production', production: 'Production',
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => { const DashboardBreadcrumb = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { isElectron } = useContext(ElectronContext)
const { goBack, goForward } = useNavigationTabs()
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState() const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
const pathSnippets = location.pathname.split('/').filter((i) => i) const pathSnippets = location.pathname.split('/').filter((i) => i)
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
<Button <Button
type='text' type='text'
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />} icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(-1)} onClick={() => (isElectron ? goBack() : navigate(-1))}
style={{ padding: '0 2px', height: '22px' }} style={{ padding: '0 2px', height: '22px' }}
/> />
<Button <Button
type='text' type='text'
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />} icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(1)} onClick={() => (isElectron ? goForward() : navigate(1))}
style={{ padding: '0 2px', height: '22px' }} style={{ padding: '0 2px', height: '22px' }}
/> />
</Space.Compact> </Space.Compact>

View File

@ -0,0 +1,21 @@
import { Route } from 'react-router-dom'
import ProductionRoutes from '../../../routes/ProductionRoutes'
import InventoryRoutes from '../../../routes/InventoryRoutes'
import FinanceRoutes from '../../../routes/FinanceRoutes'
import SalesRoutes from '../../../routes/SalesRoutes'
import ManagementRoutes from '../../../routes/ManagementRoutes'
import DeveloperRoutes from '../../../routes/DeveloperRoutes'
import ModelRoutes from '../../../routes/ModelRoutes'
export const wrapDashboardChildRoutes = (layoutElement) => (
<Route element={layoutElement}>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{ModelRoutes}
{DeveloperRoutes}
</Route>
)

View File

@ -0,0 +1,70 @@
import { useContext, useEffect, useState } from 'react'
import { Space, Tag } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext'
import Tooltip from './Tooltip'
import CloudIcon from '../../Icons/CloudIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
const DashboardDevControls = () => {
const { connecting, connected } = useContext(ApiServerContext)
const navigate = useNavigate()
const [apiServerState, setApiServerState] = useState('disconnected')
useEffect(() => {
if (connecting == true) {
setApiServerState('connecting')
} else if (connected == true) {
setApiServerState('connected')
} else {
setApiServerState('disconnected')
}
}, [connecting, connected])
if (import.meta.env.MODE !== 'development') return null
return (
<Space>
{apiServerState === 'connected' ? (
<Tooltip content='Connected to api server'>
<Tag
color='success'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
{apiServerState === 'connecting' ? (
<Tooltip content='Connecting to api server...'>
<Tag
color='warning'
style={{ marginRight: 0 }}
icon={<LoadingOutlined />}
/>
</Tooltip>
) : null}
{apiServerState === 'disconnected' ? (
<Tooltip content='Disconnected from api server'>
<Tag
color='error'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
<Tooltip content='Developer'>
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip>
</Space>
)
}
export default DashboardDevControls

View File

@ -1,5 +1,13 @@
// DashboardNavigation.js // DashboardNavigation.js
import { useContext, useEffect, useState, useMemo } from 'react' import {
useContext,
useEffect,
useState,
useMemo,
useCallback,
memo
} from 'react'
import PropTypes from 'prop-types'
import { import {
Menu, Menu,
Flex, Flex,
@ -11,7 +19,7 @@ import {
Typography, Typography,
Popover Popover
} from 'antd' } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import classNames from 'classnames'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SpotlightContext } from '../context/SpotlightContext' import { SpotlightContext } from '../context/SpotlightContext'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -35,11 +43,12 @@ import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon' import BellIcon from '../../Icons/BellIcon'
import SearchIcon from '../../Icons/SearchIcon' import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon' import SettingsIcon from '../../Icons/SettingsIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons' import DashboardWindowButtons from './DashboardWindowButtons'
import DashboardDevControls from './DashboardDevControls'
import WindowAppMenu from './WindowAppMenu' import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher' import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs'
import { import {
filterSidebarItemsByListPermission, filterSidebarItemsByListPermission,
getSidebarDefaultPath, getSidebarDefaultPath,
@ -56,6 +65,246 @@ import { getEffectiveAppearance } from '../../../database/Settings'
const { Text } = Typography const { Text } = Typography
const NAV_CENTER_FADE_STYLE = {
width: '100%',
minWidth: 0,
display: 'flex',
alignItems: 'center'
}
const NAV_ELECTRON_CENTER_FADE_STYLE = {
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center'
}
const NAV_ELECTRON_TABS_STYLE = { flex: 1, minWidth: 0, display: 'flex' }
const NAV_TRAILING_FLEX_STYLE = { marginTop: '-2px', marginRight: '6px' }
const NAV_TRAILING_SPACE_STYLE = { paddingTop: '2px', marginRight: '8px' }
const NAV_SEARCH_BUTTON_STYLE = { marginTop: '4px' }
const NAV_BELL_BUTTON_STYLE = { marginTop: '2px' }
const NAV_BADGE_STYLE = { padding: 0, fontWeight: 600 }
const NAV_UPDATE_TAG_STYLE = { cursor: 'pointer', margin: '2px 0 0 0' }
const NAV_OVERFLOW_BUTTON_STYLE = { marginBottom: '4px' }
const NAV_FADE_MS = 200
const navigationFadeClass = (visible) =>
`dashboard-navigation-fade${visible ? '' : ' dashboard-navigation-fade-hidden'}`
const useDelayedInactive = (visible) => {
const [inactive, setInactive] = useState(!visible)
useEffect(() => {
if (visible) {
setInactive(false)
return undefined
}
const timeoutId = window.setTimeout(() => setInactive(true), NAV_FADE_MS)
return () => window.clearTimeout(timeoutId)
}, [visible])
return inactive
}
const NavigationFade = memo(function NavigationFade({
visible,
children,
style,
className
}) {
const inactive = useDelayedInactive(visible)
return (
<div
className={classNames(navigationFadeClass(visible), className)}
inert={inactive ? true : undefined}
aria-hidden={inactive ? true : undefined}
style={style}
>
{children}
</div>
)
})
NavigationFade.propTypes = {
visible: PropTypes.bool,
children: PropTypes.node,
style: PropTypes.object,
className: PropTypes.string
}
const DashboardNavigationTrailingItems = memo(
function DashboardNavigationTrailingItems({
controlsReady,
isMobile,
isElectron,
unreadCount,
userProfile,
availableUpdate,
userPopoverOpen,
onUserPopoverOpenChange,
onSearch,
onNotifications,
onCheckUpdates
}) {
return (
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
{controlsReady ? (
<Space style={NAV_TRAILING_SPACE_STYLE}>
<WebAppSwitcher />
<KeyboardShortcut
shortcut='alt+q'
hint='ALT Q'
onTrigger={onSearch}
>
<Button
icon={<SearchIcon />}
type='text'
style={NAV_SEARCH_BUTTON_STYLE}
onClick={onSearch}
/>
</KeyboardShortcut>
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={NAV_BADGE_STYLE}
>
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'
onTrigger={onNotifications}
>
<Button
icon={<BellIcon />}
type='text'
style={NAV_BELL_BUTTON_STYLE}
onClick={onNotifications}
/>
</KeyboardShortcut>
</Badge>
</Space>
) : null}
<DashboardDevControls />
{controlsReady && userProfile ? (
<Space>
<Popover
content={
<UserProfilePopover
onClose={() => onUserPopoverOpenChange(false)}
/>
}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={onUserPopoverOpenChange}
arrow={false}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Popover>
</Space>
) : null}
{controlsReady && isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={NAV_UPDATE_TAG_STYLE}
color='cyan'
onClick={onCheckUpdates}
>
Update Available
</Tag>
) : null}
</Flex>
)
}
)
DashboardNavigationTrailingItems.propTypes = {
controlsReady: PropTypes.bool,
isMobile: PropTypes.bool,
isElectron: PropTypes.bool,
unreadCount: PropTypes.number,
userProfile: PropTypes.shape({
name: PropTypes.string,
username: PropTypes.string
}),
availableUpdate: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
userPopoverOpen: PropTypes.bool,
onUserPopoverOpenChange: PropTypes.func,
onSearch: PropTypes.func,
onNotifications: PropTypes.func,
onCheckUpdates: PropTypes.func
}
const DashboardElectronCenterItems = memo(
function DashboardElectronCenterItems({ menu, showNavigationLabels }) {
return (
<>
<div
className={classNames(
'dashboard-menu-container',
showNavigationLabels && 'dashboard-menu-container-navigation-labels'
)}
>
{menu}
</div>
<div style={NAV_ELECTRON_TABS_STYLE}>
<DashboardTabs />
</div>
</>
)
}
)
DashboardElectronCenterItems.propTypes = {
menu: PropTypes.node,
showNavigationLabels: PropTypes.bool
}
const DashboardNavigationTrailing = memo(function DashboardNavigationTrailing({
showControls,
isOtherApp,
isElectron,
...itemProps
}) {
return (
<>
<div className='electrobun-webkit-app-region-no-drag'>
<NavigationFade visible={!isElectron || showControls}>
<DashboardNavigationTrailingItems
isElectron={isElectron}
{...itemProps}
/>
</NavigationFade>
</div>
{isOtherApp ? <DashboardWindowButtons /> : null}
</>
)
})
DashboardNavigationTrailing.propTypes = {
showControls: PropTypes.bool,
isOtherApp: PropTypes.bool,
controlsReady: PropTypes.bool,
isMobile: PropTypes.bool,
isElectron: PropTypes.bool,
unreadCount: PropTypes.number,
userProfile: PropTypes.shape({
name: PropTypes.string,
username: PropTypes.string
}),
availableUpdate: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
userPopoverOpen: PropTypes.bool,
onUserPopoverOpenChange: PropTypes.func,
onSearch: PropTypes.func,
onNotifications: PropTypes.func,
onCheckUpdates: PropTypes.func
}
const mapSidebarItemsToMenu = (items, navigate) => const mapSidebarItemsToMenu = (items, navigate) =>
items.map((item) => { items.map((item) => {
if (item?.type === 'divider') { if (item?.type === 'divider') {
@ -82,8 +331,7 @@ const mapSidebarItemsToMenu = (items, navigate) =>
const DashboardNavigation = () => { const DashboardNavigation = () => {
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext) const { showSpotlight } = useContext(SpotlightContext)
const { connecting, connected, userSettings, userSettingsLoaded } = const { userSettings, userSettingsLoaded } = useContext(ApiServerContext)
useContext(ApiServerContext)
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const { const {
showNavigationLabels, showNavigationLabels,
@ -93,7 +341,6 @@ const DashboardNavigation = () => {
} = useThemeContext() } = useThemeContext()
const { toggleNotificationCenter, unreadCount } = const { toggleNotificationCenter, unreadCount } =
useContext(NotificationContext) useContext(NotificationContext)
const [apiServerState, setApiServerState] = useState('disconnected')
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production') const [selectedKey, setSelectedKey] = useState('production')
@ -208,17 +455,18 @@ const DashboardNavigation = () => {
[includeDevItems, location.pathname, selectedKey, userProfile] [includeDevItems, location.pathname, selectedKey, userProfile]
) )
const headerMenuItems = isMobile const headerMenuItems = useMemo(
? sidebarMenuItems () =>
: showNavigationLabels isMobile
? mainMenuItems ? sidebarMenuItems
: mainMenuItems.map((item) => ({ ...item, label: undefined })) : showNavigationLabels
? mainMenuItems
: mainMenuItems.map((item) => ({ ...item, label: undefined })),
[isMobile, mainMenuItems, showNavigationLabels, sidebarMenuItems]
)
const [userPopoverOpen, setUserPopoverOpen] = useState(false) const [userPopoverOpen, setUserPopoverOpen] = useState(false)
const [anyModalVisible, setAnyModalVisible] = useState(false)
const userPopoverContent = (
<UserProfilePopover onClose={() => setUserPopoverOpen(false)} />
)
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -228,19 +476,12 @@ const DashboardNavigation = () => {
setSelectedKey(nextItem?.key || sectionKey) setSelectedKey(nextItem?.key || sectionKey)
}, [location.pathname, mainMenuItems]) }, [location.pathname, mainMenuItems])
useEffect(() => { const handleMainMenuClick = useCallback(
if (connecting == true) { ({ key }) => {
setApiServerState('connecting') navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
} else if (connected == true) { },
setApiServerState('connected') [includeDev, navigate, userProfile]
} else { )
setApiServerState('disconnected')
}
}, [connecting, connected])
const handleMainMenuClick = ({ key }) => {
navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
}
useEffect(() => { useEffect(() => {
if (!isElectron || !setSidebarViewMenu) return if (!isElectron || !setSidebarViewMenu) return
@ -254,44 +495,141 @@ const DashboardNavigation = () => {
const showDesktopLogo = !isElectron && !isMobile const showDesktopLogo = !isElectron && !isMobile
const showMobileLogo = !isElectron && isMobile const showMobileLogo = !isElectron && isMobile
const showControls = !isElectron || authenticated useEffect(() => {
if (!isElectron) {
setAnyModalVisible(false)
return
}
const menu = ( const isModalWrapVisible = (node) => {
<Menu if (!(node instanceof Element)) return false
mode='horizontal' if (node.getAttribute('aria-hidden') === 'true') return false
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels && !isMobile ? 'no-navigation-labels' : ''}${isMobile ? 'mobile-menu' : ''}`} const style = window.getComputedStyle(node)
items={headerMenuItems} return (
style={{ style.display !== 'none' &&
flexWrap: 'wrap', style.visibility !== 'hidden' &&
flexGrow: 1, style.pointerEvents !== 'none'
border: 0, )
minWidth: 0 }
}}
onClick={isMobile ? undefined : handleMainMenuClick} const updateModalVisibility = () => {
selectedKeys={[isMobile ? selectedSidebarItemKey : selectedKey]} const wraps = document.querySelectorAll('.ant-modal-wrap')
overflowedIndicator={ setAnyModalVisible(Array.from(wraps).some(isModalWrapVisible))
<Button }
type='text'
icon={<MenuIcon />} let frameId = 0
style={{ marginBottom: '4px' }} const scheduleUpdate = () => {
/> if (frameId) return
} frameId = window.requestAnimationFrame(() => {
frameId = 0
updateModalVisibility()
})
}
updateModalVisibility()
const observer = new MutationObserver(scheduleUpdate)
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'aria-hidden']
})
return () => {
observer.disconnect()
if (frameId) window.cancelAnimationFrame(frameId)
}
}, [isElectron])
const controlsReady = !isElectron || authenticated
const showControls = controlsReady && !anyModalVisible
const handleSearch = useCallback(() => {
showSpotlight()
}, [showSpotlight])
const handleNotifications = useCallback(() => {
toggleNotificationCenter()
}, [toggleNotificationCenter])
const handleCheckUpdates = useCallback(() => {
checkForUpdates()
}, [checkForUpdates])
const overflowedIndicator = useMemo(
() => (
<Button
type='text'
icon={<MenuIcon />}
style={NAV_OVERFLOW_BUTTON_STYLE}
/>
),
[]
)
const menu = useMemo(
() => (
<Menu
mode='horizontal'
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels && !isMobile ? 'no-navigation-labels' : ''}${isMobile ? 'mobile-menu' : ''}`}
items={headerMenuItems}
style={{
flexWrap: 'wrap',
flexGrow: isElectron && !isMobile ? 0 : 1,
border: 0,
minWidth: 0
}}
onClick={isMobile ? undefined : handleMainMenuClick}
selectedKeys={[isMobile ? selectedSidebarItemKey : selectedKey]}
overflowedIndicator={overflowedIndicator}
/>
),
[
handleMainMenuClick,
headerMenuItems,
isElectron,
isMobile,
overflowedIndicator,
selectedKey,
selectedSidebarItemKey,
showNavigationLabels
]
)
const navigationTrailing = (
<DashboardNavigationTrailing
showControls={showControls}
isOtherApp={isOtherApp}
controlsReady={controlsReady}
isMobile={isMobile}
isElectron={isElectron}
unreadCount={unreadCount}
userProfile={userProfile}
availableUpdate={availableUpdate}
userPopoverOpen={userPopoverOpen}
onUserPopoverOpenChange={setUserPopoverOpen}
onSearch={handleSearch}
onNotifications={handleNotifications}
onCheckUpdates={handleCheckUpdates}
/> />
) )
const navigationContents = ( const navigationLeading = (
<Flex style={{ width: '100%' }} align='center'> <>
{isMacOSApp ? <DashboardWindowButtons /> : null} {isMacOSApp ? <DashboardWindowButtons /> : null}
{isOtherApp ? ( {isOtherApp ? (
<> <>
<WindowAppMenu />{' '} <WindowAppMenu />{' '}
<Divider <Divider
type='vertical' type='vertical'
style={{ height: '14px', margin: '3px 3px 0 1.5px' }} style={{
height: '14px',
margin: showNavigationLabels
? '3px 3px 0 1.5px'
: '3px 0px 0 1.5px'
}}
/> />
</> </>
) : null} ) : null}
{showControls && isMobile && menu} {isMobile ? (
<NavigationFade visible={showControls}>{menu}</NavigationFade>
) : null}
{showDesktopLogo == true ? ( {showDesktopLogo == true ? (
<FarmControlLogo <FarmControlLogo
style={{ style={{
@ -308,146 +646,77 @@ const DashboardNavigation = () => {
}} }}
/> />
) : null} ) : null}
<div style={{ flexGrow: 1 }}> </>
{showControls && !isMobile && menu} )
{!showControls && (
<Text
type='secondary'
style={{
fontSize: '14px',
marginLeft: '8px',
userSelect: 'none',
'--webkit-user-select': 'none'
}}
>
Farm Control
</Text>
)}
</div>
<div className='electrobun-webkit-app-region-no-drag'> const showWindowTitle = isElectron && !showControls
<Flex
gap={'small'} const windowTitle = (
align='center' <NavigationFade
style={{ marginTop: '-2px', marginRight: '6px' }} visible={showWindowTitle}
> className='dashboard-navigation-window-title'
{showControls && ( >
<Space style={{ paddingTop: '2px', marginRight: '8px' }}> <Text type='secondary'>Farm Control</Text>
<WebAppSwitcher /> </NavigationFade>
<KeyboardShortcut )
shortcut='alt+q'
hint='ALT Q' const navigationCenterBorder = (
onTrigger={() => showSpotlight()} <div
> className={`dashboard-navigation-center-border${
<Button showControls ? ' dashboard-navigation-center-border-hidden' : ''
icon={<SearchIcon />} }`}
type='text' aria-hidden='true'
style={{ marginTop: '4px' }} />
onClick={() => showSpotlight()} )
/>
</KeyboardShortcut> const navigationContents = (
<Badge <Flex style={{ width: '100%' }} align='center'>
count={unreadCount} {navigationLeading}
size='small' <div className='dashboard-navigation-center'>
offset={[-5, 8]} {!isMobile ? (
style={{ padding: 0, fontWeight: 600 }} <NavigationFade visible style={NAV_CENTER_FADE_STYLE}>
> {menu}
<KeyboardShortcut </NavigationFade>
shortcut='alt+n' ) : null}
hint='ALT N' {windowTitle}
onTrigger={() => toggleNotificationCenter()}
>
<Button
icon={<BellIcon />}
type='text'
style={{ marginTop: '2px' }}
onClick={() => toggleNotificationCenter()}
/>
</KeyboardShortcut>
</Badge>
</Space>
)}
{import.meta.env.MODE === 'development' && (
<Space>
{apiServerState === 'connected' ? (
<Tooltip content='Connected to api server'>
<Tag
color='success'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
{apiServerState === 'connecting' ? (
<Tooltip content='Connecting to api erver...'>
<Tag
color='warning'
style={{ marginRight: 0 }}
icon={<LoadingOutlined />}
/>
</Tooltip>
) : null}
{apiServerState === 'disconnected' ? (
<Tooltip content='Disconnected from api server'>
<Tag
color='error'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
<Tooltip content='Developer'>
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip>
</Space>
)}
{showControls && userProfile ? (
<Space>
<Popover
content={userPopoverContent}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={setUserPopoverOpen}
arrow={false}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Popover>
</Space>
) : null}
{showControls && isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
color='cyan'
onClick={() => checkForUpdates()}
>
Update Available
</Tag>
) : null}
</Flex>
</div> </div>
{isOtherApp ? <DashboardWindowButtons /> : null} {navigationTrailing}
</Flex> </Flex>
) )
const electronDragClass =
isFullScreen || (isMaximized && isOtherApp)
? 'electrobun-webkit-app-region-no-drag'
: 'electrobun-webkit-app-region-drag'
return ( return (
<> <>
{isElectron ? ( {isElectron ? (
<Flex <Flex
className={`ant-menu-horizontal electron-navigation-wrapper ant-menu-light ${isFullScreen || (isMaximized && isOtherApp) ? 'electrobun-webkit-app-region-no-drag' : 'electrobun-webkit-app-region-drag'}`} className={`electron-navigation-wrapper ${electronDragClass}`}
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }} style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
> >
{navigationContents} <Flex align='center' className='electron-navigation-leading'>
{navigationLeading}
</Flex>
<div className='dashboard-navigation-center'>
{!isMobile ? (
<NavigationFade
visible={showControls}
style={NAV_ELECTRON_CENTER_FADE_STYLE}
>
<DashboardElectronCenterItems
menu={menu}
showNavigationLabels={showNavigationLabels}
/>
</NavigationFade>
) : null}
{windowTitle}
{navigationCenterBorder}
</div>
<Flex align='center' className='electron-navigation-trailing'>
{navigationTrailing}
</Flex>
</Flex> </Flex>
) : ( ) : (
<Flex vertical> <Flex vertical>

View File

@ -25,6 +25,7 @@ import usePageLayout, {
normalizeColumnProportions normalizeColumnProportions
} from '../hooks/usePageLayout' } from '../hooks/usePageLayout'
import { OverviewLayoutContext } from '../context/OverviewLayoutContext' import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const flattenOverviewSections = (sectionList = []) => { const flattenOverviewSections = (sectionList = []) => {
const leaves = {} const leaves = {}
@ -124,6 +125,16 @@ const DashboardOverviewPage = ({
onOpenActionsModal, onOpenActionsModal,
sections = [] sections = []
}) => { }) => {
const overviewSection = pageName.endsWith('Overview')
? pageName.slice(0, -'Overview'.length)
: ''
const overviewTitle = overviewSection
? `Overview - ${overviewSection}`
: pageName
const overviewIconKey = overviewSection
? `${overviewSection.charAt(0).toLowerCase()}${overviewSection.slice(1)}`
: undefined
useNavigationTabPage({ title: overviewTitle, iconKey: overviewIconKey })
const [savedCollapseState, updateCollapseState] = useCollapseState( const [savedCollapseState, updateCollapseState] = useCollapseState(
pageName, pageName,
collapseDefaults collapseDefaults

View File

@ -13,8 +13,8 @@ import { ElectronContext } from '../context/ElectronContext'
import AdaptiveSplitter from './AdaptiveSplitter' import AdaptiveSplitter from './AdaptiveSplitter'
const COLLAPSE_BELOW_PX = 200 const COLLAPSE_BELOW_PX = 200
const DEFAULT_SIDEBAR_PX = 300 const DEFAULT_SIDEBAR_PX = 285
const MAX_SIDEBAR_PX = 400 const MAX_SIDEBAR_PX = 305
const COLLAPSED_WIDTH_PX = 80 const COLLAPSED_WIDTH_PX = 80
const ELECTRON_COLLAPSED_WIDTH_PX = 55 const ELECTRON_COLLAPSED_WIDTH_PX = 55
const EXPAND_DRAG_PX = 10 const EXPAND_DRAG_PX = 10

View File

@ -0,0 +1,244 @@
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-router-dom'
import { TableStateProvider } from '../context/TableStateContext'
import {
NavigationTabActiveContext,
NavigationTabIdContext,
createNavigationTabActiveStore,
useNavigationTabs
} from '../context/NavigationTabsContext'
import { wrapDashboardChildRoutes } from './DashboardChildRoutes'
const locationsEqual = (left, right) =>
Boolean(
left &&
right &&
left.pathname === right.pathname &&
(left.search || '') === (right.search || '') &&
(left.hash || '') === (right.hash || '')
)
const toTabLocation = (tabId, source) => ({
pathname: source?.pathname || '/',
search: source?.search ?? '',
hash: source?.hash ?? '',
state: source?.state ?? null,
key: `tab-${tabId}`
})
const reuseTabLocation = (tabId, previous, next) => {
if (
previous &&
locationsEqual(previous, next) &&
previous.key === `tab-${tabId}` &&
(previous.state ?? null) === (next.state ?? null)
) {
return previous
}
return toTabLocation(tabId, next)
}
const entryToLocation = (entry, fallbackLocation, tabId) =>
toTabLocation(tabId, {
pathname: entry?.pathname || fallbackLocation.pathname,
search: entry?.search ?? fallbackLocation.search ?? '',
hash: entry?.hash ?? fallbackLocation.hash ?? '',
state: entry?.state ?? null
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1]
const tabMatchesLocation = (tab, loc) => {
const entry = getTabCurrentEntry(tab)
if (!entry || !loc) return false
return locationsEqual(entry, loc)
}
const locationBelongsToLeavingTab = (tab, frozen, loc) => {
if (!loc) return false
if (frozen && locationsEqual(frozen, loc)) return true
return tabMatchesLocation(tab, loc)
}
const CachedTabRouteLayout = () => (
<TableStateProvider>
<Outlet />
</TableStateProvider>
)
const areLocationsEqual = (left, right) =>
left === right ||
(locationsEqual(left, right) &&
left?.key === right?.key &&
(left?.state ?? null) === (right?.state ?? null))
const arePanePropsEqual = (prev, next) =>
prev.tabId === next.tabId &&
prev.isActive === next.isActive &&
prev.registerTabPane === next.registerTabPane &&
areLocationsEqual(prev.loc, next.loc)
const DashboardTabPaneRoutes = memo(function DashboardTabPaneRoutes({ loc }) {
const tabRoutes = useMemo(
() => wrapDashboardChildRoutes(<CachedTabRouteLayout />),
[]
)
const locationContextValue = useMemo(
() => ({ location: loc, navigationType: 'POP' }),
[loc]
)
return (
<UNSAFE_LocationContext.Provider value={locationContextValue}>
<Routes location={loc}>{tabRoutes}</Routes>
</UNSAFE_LocationContext.Provider>
)
}, (prev, next) => areLocationsEqual(prev.loc, next.loc))
DashboardTabPaneRoutes.displayName = 'DashboardTabPaneRoutes'
DashboardTabPaneRoutes.propTypes = {
loc: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
hash: PropTypes.string,
key: PropTypes.string,
state: PropTypes.any
}).isRequired
}
const DashboardTabPane = memo(function DashboardTabPane({
tabId,
isActive,
loc,
registerTabPane
}) {
const storeRef = useRef(null)
if (storeRef.current == null) {
storeRef.current = createNavigationTabActiveStore(isActive)
}
useLayoutEffect(() => {
storeRef.current.setActive(isActive)
}, [isActive])
return (
<div
ref={(element) => registerTabPane(tabId, element)}
data-tab-id={tabId}
className={classNames('dashboard-tab-pane', {
'dashboard-tab-pane-active': isActive,
'dashboard-tab-pane-inactive': !isActive
})}
aria-hidden={!isActive}
inert={!isActive}
>
<NavigationTabIdContext.Provider value={tabId}>
<NavigationTabActiveContext.Provider value={storeRef.current}>
<DashboardTabPaneRoutes loc={loc} />
</NavigationTabActiveContext.Provider>
</NavigationTabIdContext.Provider>
</div>
)
}, arePanePropsEqual)
DashboardTabPane.displayName = 'DashboardTabPane'
DashboardTabPane.propTypes = {
tabId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
loc: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
hash: PropTypes.string,
key: PropTypes.string,
state: PropTypes.any
}).isRequired,
registerTabPane: PropTypes.func.isRequired
}
const DashboardTabPanes = () => {
const location = useLocation()
const { tabs, activeTabId, registerTabPane } = useNavigationTabs()
const frozenRef = useRef(new Map())
const prevActiveIdRef = useRef(activeTabId)
const lastLocationRef = useRef(location)
const pendingRestoreRef = useRef(false)
const prevActiveId = prevActiveIdRef.current
const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId)
if (switchedTabs) {
const previousTab = tabs.find((tab) => tab.id === prevActiveId)
const previousFrozen = frozenRef.current.get(prevActiveId)
const lastLoc = lastLocationRef.current
if (locationBelongsToLeavingTab(previousTab, previousFrozen, lastLoc)) {
frozenRef.current.set(
prevActiveId,
reuseTabLocation(prevActiveId, previousFrozen, lastLoc)
)
} else if (!previousFrozen && previousTab) {
frozenRef.current.set(
prevActiveId,
entryToLocation(getTabCurrentEntry(previousTab), lastLoc, prevActiveId)
)
}
pendingRestoreRef.current = true
}
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (activeTab && tabMatchesLocation(activeTab, location)) {
pendingRestoreRef.current = false
frozenRef.current.set(
activeTabId,
reuseTabLocation(
activeTabId,
frozenRef.current.get(activeTabId),
location
)
)
}
for (const tabId of [...frozenRef.current.keys()]) {
if (!tabs.some((tab) => tab.id === tabId)) {
frozenRef.current.delete(tabId)
}
}
prevActiveIdRef.current = activeTabId
lastLocationRef.current = location
if (tabs.length === 0) {
return <Outlet />
}
return (
<div className='dashboard-tab-panes'>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
const loc =
isActive && !pendingRestoreRef.current
? reuseTabLocation(tab.id, frozenRef.current.get(tab.id), location)
: frozenRef.current.get(tab.id) ||
entryToLocation(getTabCurrentEntry(tab), location, tab.id)
frozenRef.current.set(tab.id, loc)
return (
<DashboardTabPane
key={tab.id}
tabId={tab.id}
isActive={isActive}
loc={loc}
registerTabPane={registerTabPane}
/>
)
})}
</div>
)
}
export default DashboardTabPanes

View File

@ -0,0 +1,89 @@
import { useLayoutEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { LoadingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
const DashboardTabPreview = ({ src, srcTheme, loading = false }) => {
const { isDarkMode } = useThemeContext()
const currentTheme = isDarkMode ? 'dark' : 'light'
const usableSrc = src && srcTheme === currentTheme ? src : null
const lastSrcRef = useRef(usableSrc)
const [overlaySrc, setOverlaySrc] = useState(usableSrc)
const [revealing, setRevealing] = useState(false)
useLayoutEffect(() => {
if (srcTheme !== currentTheme) {
lastSrcRef.current = null
setOverlaySrc(null)
setRevealing(false)
return
}
if (loading) {
const locked = lastSrcRef.current || usableSrc || null
lastSrcRef.current = locked
setOverlaySrc(locked)
setRevealing(false)
return
}
if (usableSrc) {
lastSrcRef.current = usableSrc
}
setRevealing(true)
}, [currentTheme, loading, srcTheme, usableSrc])
const handleOverlayTransitionEnd = (event) => {
if (event.propertyName !== 'opacity' || !revealing) return
setOverlaySrc(null)
setRevealing(false)
}
return (
<div
className={classNames('dashboard-tab-preview', {
'dark-mode': isDarkMode
})}
>
<div className='dashboard-tab-preview-stack'>
{usableSrc ? (
<img
src={usableSrc}
alt=''
className='dashboard-tab-preview-svg dashboard-tab-preview-svg-current'
/>
) : null}
{overlaySrc ? (
<img
src={overlaySrc}
alt=''
className={classNames(
'dashboard-tab-preview-svg',
'dashboard-tab-preview-svg-previous',
{ 'is-revealing': revealing }
)}
onTransitionEnd={handleOverlayTransitionEnd}
/>
) : null}
{loading ? (
<div className='dashboard-tab-preview-loading'>
<LoadingOutlined
spin
className='dashboard-tab-preview-loading-icon'
/>
</div>
) : null}
</div>
</div>
)
}
DashboardTabPreview.propTypes = {
src: PropTypes.string,
srcTheme: PropTypes.oneOf(['dark', 'light']),
loading: PropTypes.bool
}
export default DashboardTabPreview

View File

@ -0,0 +1,803 @@
import PropTypes from 'prop-types'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
memo
} from 'react'
import { Button, Flex, Popover } from 'antd'
import DashboardTabPreview from './DashboardTabPreview'
import ElipsisText from './ElipsisText'
import classNames from 'classnames'
import ScrollBox from './ScrollBox'
import PlusIcon from '../../Icons/PlusIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import HomeIcon from '../../Icons/HomeIcon'
import { getModelByName } from '../../../database/ObjectModels'
import { getSidebarIconComponent } from '../../Icons/sidebarIconMap'
import {
useNavigationTabs,
useTabPreview
} from '../context/NavigationTabsContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
import { hasExternalTabDrag, writeTabDragData } from './tabDrag'
const DashboardTabChrome = () => (
<>
<span className='dashboard-tab-item-outline-container' aria-hidden='true'>
<span className='dashboard-tab-item-outline' aria-hidden='true' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-left'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-right'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
</>
)
const DashboardTabLabel = ({ tab, onClose }) => (
<Flex align='center' gap={6} className='dashboard-tabs-label'>
<ElipsisText>{tab.title || 'Farm Control'}</ElipsisText>
<span
className='dashboard-tabs-close'
role='button'
tabIndex={-1}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onClose?.(tab.id)
}}
onMouseDown={(event) => event.stopPropagation()}
>
<XMarkIcon style={{ fontSize: 10 }} />
</span>
</Flex>
)
DashboardTabLabel.propTypes = {
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string
}).isRequired,
onClose: PropTypes.func
}
const DashboardTabItem = memo(function DashboardTabItem({
htmlId = undefined,
tab,
tabIndex,
isSelected,
isDragging,
dropPosition,
onDragStart,
onDragEnd,
onClick,
onKeyDown,
onDragOver,
onDrop,
onClose,
captureTabPreview
}) {
const {
src: previewSrc,
theme: previewTheme,
capturing: previewCapturing
} = useTabPreview(tab.id)
const [previewOpen, setPreviewOpen] = useState(false)
const previewTargetRef = useRef(null)
const model = tab.modelName ? getModelByName(tab.modelName) : null
const SidebarIcon = getSidebarIconComponent(tab.iconKey)
const Icon = model?.icon || SidebarIcon || HomeIcon
const handlePreviewOpenChange = useCallback(
(nextOpen) => {
if (isSelected || isDragging) {
setPreviewOpen(false)
return
}
setPreviewOpen(nextOpen)
if (nextOpen) {
void captureTabPreview(tab.id)
}
},
[captureTabPreview, isDragging, isSelected, tab.id]
)
useEffect(() => {
if (isSelected || isDragging) {
setPreviewOpen(false)
}
}, [isDragging, isSelected])
const tabButton = (
<button
ref={previewTargetRef}
type='button'
role='tab'
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
className={classNames(
'dashboard-tab-item',
'electrobun-webkit-app-region-no-drag',
{
'dashboard-tab-item-active': isSelected,
'dashboard-tab-item-dragging': isDragging,
'dashboard-tab-item-drop-before': dropPosition === 'before',
'dashboard-tab-item-drop-after': dropPosition === 'after'
}
)}
draggable
onDragStart={(event) => onDragStart(event, tab.id)}
onDragEnd={onDragEnd}
id={htmlId}
onClick={() => onClick(tab.id)}
onKeyDown={(event) => onKeyDown(event, tabIndex)}
onDragOver={(event) => onDragOver(event, tab)}
onDrop={(event) => onDrop(event, tab)}
>
<DashboardTabChrome />
<span className='dashboard-tab-item-body'>
<span className='dashboard-tab-item-icon'>
<Icon style={{ fontSize: 14 }} color='secondary' />
</span>
<DashboardTabLabel tab={tab} onClose={onClose} />
</span>
</button>
)
if (isSelected) {
return tabButton
}
return (
<Popover
trigger='hover'
placement='bottom'
arrow={false}
mouseEnterDelay={1}
mouseLeaveDelay={0}
destroyOnHidden
open={isDragging ? false : previewOpen}
onOpenChange={handlePreviewOpenChange}
content={
<DashboardTabPreview
src={previewSrc}
srcTheme={previewTheme}
loading={previewCapturing}
/>
}
classNames={{ root: 'dashboard-tab-preview-popover' }}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
>
{tabButton}
</Popover>
)
})
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = {
htmlId: PropTypes.string,
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string,
modelName: PropTypes.string,
iconKey: PropTypes.string
}).isRequired,
tabIndex: PropTypes.number.isRequired,
isSelected: PropTypes.bool,
isDragging: PropTypes.bool,
dropPosition: PropTypes.oneOf(['before', 'after']),
onDragStart: PropTypes.func.isRequired,
onDragEnd: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
onKeyDown: PropTypes.func.isRequired,
onDragOver: PropTypes.func.isRequired,
onDrop: PropTypes.func.isRequired,
onClose: PropTypes.func,
captureTabPreview: PropTypes.func
}
const STICKY_EDGE_INSET = 20
const STICKY_LINE_CORNER_GAP = 16
const STICKY_MASK_INSET = 58
const measureTabMetrics = (container) => {
const button = container.querySelector('.dashboard-tab-item')
const icon = container.querySelector('.dashboard-tab-item-icon')
if (!button || !icon) {
return {
naturalWidth: container.offsetWidth,
minWidth: container.offsetWidth
}
}
const styles = window.getComputedStyle(button)
const minWidth = Math.ceil(
icon.getBoundingClientRect().width +
parseFloat(styles.paddingLeft) +
parseFloat(styles.paddingRight) +
parseFloat(styles.borderLeftWidth) +
parseFloat(styles.borderRightWidth)
)
return {
naturalWidth: container.offsetWidth,
minWidth: Math.max(minWidth, 1)
}
}
const hideStickyClone = (cloneEl) => {
if (!cloneEl) return
delete cloneEl.dataset.edge
delete cloneEl.dataset.faded
cloneEl.style.removeProperty('--tab-sticky-width')
cloneEl.style.removeProperty('--tab-label-opacity')
cloneEl.style.left = ''
cloneEl.style.right = ''
cloneEl.setAttribute('inert', '')
}
const setSimplebarMaskInset = (maskEl, edge, inset) => {
if (!maskEl) return
const wrapEl = maskEl.closest('.dashboard-tabs-wrap') || maskEl
if (!edge) {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.removeProperty('--tab-mask-right')
return
}
if (edge === 'left') {
wrapEl.style.setProperty('--tab-mask-left', `${inset}px`)
wrapEl.style.removeProperty('--tab-mask-right')
} else {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.setProperty('--tab-mask-right', `${inset}px`)
}
}
const setScrollEdgeFades = (root, scrollEl) => {
if (!root) return
if (!scrollEl) {
root.dataset.atStart = ''
root.dataset.atEnd = ''
return
}
const atStart = scrollEl.scrollLeft <= 1
const atEnd =
scrollEl.scrollLeft + scrollEl.clientWidth >= scrollEl.scrollWidth - 1
if (atStart) root.dataset.atStart = ''
else delete root.dataset.atStart
if (atEnd) root.dataset.atEnd = ''
else delete root.dataset.atEnd
}
const setStickyStripLine = (root, lineEl, edge, lineInset) => {
if (root) {
if (!edge) delete root.dataset.stickyEdge
else root.dataset.stickyEdge = edge
}
const padding = 4
if (!lineEl) return
if (!edge) {
delete lineEl.dataset.edge
lineEl.style.left = ''
lineEl.style.right = ''
return
}
lineEl.dataset.edge = edge
if (edge === 'left') {
lineEl.style.left = `${lineInset + padding}px`
lineEl.style.right = '0px'
} else {
lineEl.style.left = '0px'
lineEl.style.right = `${lineInset + padding}px`
}
}
const setOriginalStickyState = (container, sticky) => {
if (!container) return
if (sticky) {
container.dataset.stickyPlaceholder = 'true'
container.setAttribute('inert', '')
} else {
delete container.dataset.stickyPlaceholder
container.removeAttribute('inert')
}
}
const bindActiveTabStickyScroll = (scrollEl, list, cloneEl, root, lineEl) => {
const state = {
activeEl: null,
updating: false
}
const maskEl = scrollEl.closest('.simplebar-mask')
const wrapperEl = scrollEl.closest('.simplebar-wrapper') || scrollEl
const clearStickyVisuals = () => {
hideStickyClone(cloneEl)
setStickyStripLine(root, lineEl, null)
setSimplebarMaskInset(maskEl, null)
}
const update = () => {
if (state.updating) return
state.updating = true
try {
setScrollEdgeFades(root, scrollEl)
const container = list.querySelector(
'.dashboard-tab-item-container-active'
)
if (state.activeEl && state.activeEl !== container) {
setOriginalStickyState(state.activeEl, false)
}
state.activeEl = container
if (!container || !cloneEl) {
clearStickyVisuals()
return
}
const metrics = measureTabMetrics(container)
const naturalWidth = metrics.naturalWidth
const minWidth = Math.min(metrics.minWidth, naturalWidth)
const maxShrink = Math.max(0, naturalWidth - minWidth)
const scrollLeft = scrollEl.scrollLeft
const viewportWidth = wrapperEl.clientWidth
if (viewportWidth <= 0) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const wrapperRect = wrapperEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const naturalLeft = scrollLeft + (containerRect.left - wrapperRect.left)
const isFirstTab = container === list.firstElementChild
if (isFirstTab && scrollLeft < 1) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const overflowLeft = scrollLeft + STICKY_EDGE_INSET - naturalLeft
const overflowRight =
naturalLeft +
naturalWidth -
(scrollLeft + viewportWidth - STICKY_EDGE_INSET)
let edge = null
let overflow = 0
if (overflowLeft > 0.5 && overflowLeft >= overflowRight) {
edge = 'left'
overflow = overflowLeft
} else if (overflowRight > 0.5) {
edge = 'right'
overflow = overflowRight
}
if (!edge) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const shrink = Math.min(overflow, maxShrink)
const visualWidth = Math.max(minWidth, naturalWidth - shrink)
const shrinkProgress = maxShrink === 0 ? 1 : shrink / maxShrink
const fade = 1 - shrinkProgress
const maskInset = STICKY_MASK_INSET * shrinkProgress
setOriginalStickyState(container, true)
cloneEl.removeAttribute('inert')
cloneEl.dataset.edge = edge
if (fade < 0.25) cloneEl.dataset.faded = 'true'
else delete cloneEl.dataset.faded
const parentRect =
cloneEl.offsetParent?.getBoundingClientRect() || wrapperRect
const cloneOffset =
edge === 'left'
? wrapperRect.left - parentRect.left + STICKY_EDGE_INSET
: parentRect.right - wrapperRect.right + STICKY_EDGE_INSET
if (edge === 'left') {
cloneEl.style.left = `${cloneOffset}px`
cloneEl.style.right = 'auto'
} else {
cloneEl.style.left = 'auto'
cloneEl.style.right = `${cloneOffset}px`
}
cloneEl.style.setProperty('--tab-sticky-width', `${visualWidth}px`)
cloneEl.style.setProperty('--tab-label-opacity', String(fade))
setSimplebarMaskInset(maskEl, edge, maskInset)
setStickyStripLine(
root,
lineEl,
edge,
cloneOffset + visualWidth + STICKY_LINE_CORNER_GAP
)
} finally {
state.updating = false
}
}
let frame = 0
const scheduleUpdate = () => {
if (frame) return
frame = window.requestAnimationFrame(() => {
frame = 0
update()
})
}
update()
scrollEl.addEventListener('scroll', scheduleUpdate, { passive: true })
window.addEventListener('resize', scheduleUpdate)
const observer = new ResizeObserver(scheduleUpdate)
observer.observe(scrollEl)
if (wrapperEl !== scrollEl) observer.observe(wrapperEl)
observer.observe(list)
return () => {
scrollEl.removeEventListener('scroll', scheduleUpdate)
window.removeEventListener('resize', scheduleUpdate)
observer.disconnect()
if (frame) window.cancelAnimationFrame(frame)
setOriginalStickyState(state.activeEl, false)
clearStickyVisuals()
delete root.dataset.atStart
delete root.dataset.atEnd
}
}
const useActiveTabStickyScroll = (activeTabId, tabs) => {
const rootRef = useRef(null)
const listRef = useRef(null)
const cloneRef = useRef(null)
const lineRef = useRef(null)
const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}`
useLayoutEffect(() => {
const root = rootRef.current
const list = listRef.current
const cloneEl = cloneRef.current
const lineEl = lineRef.current
if (!root || !list) return undefined
let cancelled = false
let teardown = () => {}
let frame = 0
const tryBind = () => {
if (cancelled) return
const scrollEl = root.querySelector('.simplebar-content-wrapper')
if (!scrollEl) {
frame = window.requestAnimationFrame(tryBind)
return
}
teardown = bindActiveTabStickyScroll(
scrollEl,
list,
cloneEl,
root,
lineEl
)
}
tryBind()
return () => {
cancelled = true
if (frame) window.cancelAnimationFrame(frame)
teardown()
}
}, [tabLayoutKey])
return { rootRef, listRef, cloneRef, lineRef }
}
const DashboardTabs = () => {
const {
tabs,
activeTabId,
selectTab,
addTab,
registerTabStripRoot,
closeTab,
reorderTabs,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId,
tabs
)
const [draggedValue, setDraggedValue] = useState(null)
const [dropTarget, setDropTarget] = useState(null)
const clearDragState = useCallback(() => {
setDraggedValue(null)
setDropTarget(null)
}, [])
const handleItemDragStart = (event, tabId) => {
const tab = tabs.find((item) => item.id === tabId)
if (!tab) return
event.stopPropagation()
const item = event.currentTarget
if (item) {
const rect = item.getBoundingClientRect()
event.dataTransfer.setDragImage(
item,
event.clientX - rect.left,
event.clientY - rect.top
)
}
setDraggedValue(tabId)
event.dataTransfer.effectAllowed = 'move'
writeTabDragData(event, {
tab,
sourceWindowId: getDesktopWindowId()
})
handleTabDragStart(tabId)
}
const handleItemDragEnd = () => {
handleTabDragEnd()
clearDragState()
}
const handleItemDragOver = (event, tab) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (draggedValue != null && String(tab.id) === String(draggedValue)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'move'
const rect = event.currentTarget.getBoundingClientRect()
setDropTarget({
value: tab.id,
insertBefore: event.clientX < rect.left + rect.width / 2
})
}
const handleItemDrop = (event, tab) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
event.preventDefault()
event.stopPropagation()
const target = dropTarget || {
value: tab.id,
insertBefore: true
}
if (isExternal) {
void handleExternalTabDrop(target.value, target.insertBefore)
clearDragState()
return
}
if (draggedValue == null) return
if (String(draggedValue) !== String(target.value)) {
reorderTabs(draggedValue, target.value, target.insertBefore)
}
clearDragState()
}
const handleListDragOver = (event) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const lastTab = tabs[tabs.length - 1]
if (!lastTab) return
setDropTarget({
value: lastTab.id,
insertBefore: false
})
}
const handleListDrop = (event) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.stopPropagation()
const lastTab = tabs[tabs.length - 1]
if (!lastTab) {
clearDragState()
return
}
if (isExternal) {
void handleExternalTabDrop(lastTab.id, false)
clearDragState()
return
}
if (String(draggedValue) !== String(lastTab.id)) {
reorderTabs(draggedValue, lastTab.id, false)
}
clearDragState()
}
const handleStripDragOver = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const handleStripDrop = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
const lastTab = tabs[tabs.length - 1]
void handleExternalTabDrop(lastTab?.id, false)
}
const handleKeyDown = (event, tabIndex) => {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId)
const startIndex = currentIndex === -1 ? tabIndex : currentIndex
const nextIndex = (startIndex + direction + tabs.length) % tabs.length
selectTab(tabs[nextIndex].id)
}
const setRootRef = useCallback(
(element) => {
rootRef.current = element
registerTabStripRoot(element)
},
[registerTabStripRoot]
)
const isReordering = draggedValue != null || dropTarget != null
const activeTab = tabs.find((tab) => tab.id === activeTabId)
const activeTabIndex = activeTab
? tabs.findIndex((tab) => tab.id === activeTabId)
: 0
const tabItemProps = {
onDragStart: handleItemDragStart,
onDragEnd: handleItemDragEnd,
onClick: selectTab,
onKeyDown: handleKeyDown,
onDragOver: handleItemDragOver,
onDrop: handleItemDrop,
onClose: closeTab,
captureTabPreview
}
return (
<Flex
ref={setRootRef}
align='flex-start'
gap={4}
className={classNames(
'dashboard-tabs',
'electrobun-webkit-app-region-drag'
)}
style={{ flex: 1, minWidth: 0 }}
onDragOver={handleStripDragOver}
onDrop={handleStripDrop}
>
<ScrollBox className='dashboard-tabs-wrap' horizontal>
<Flex align='flex-start' gap={5} className='dashboard-tabs-inner'>
<div
ref={listRef}
className={classNames('dashboard-tabs-list', {
'dashboard-tabs-reordering': isReordering
})}
role='tablist'
onDragOver={handleListDragOver}
onDrop={handleListDrop}
>
{tabs.map((tab, index) => {
const isSelected = tab.id === activeTabId
const isDragging =
draggedValue != null && String(draggedValue) === String(tab.id)
const isDropTarget =
dropTarget != null &&
String(dropTarget.value) === String(tab.id) &&
String(draggedValue) !== String(tab.id)
return (
<div
key={tab.id}
className={classNames('dashboard-tab-item-container', {
'dashboard-tab-item-container-active': isSelected
})}
>
<DashboardTabItem
tab={tab}
tabIndex={index}
isSelected={isSelected}
isDragging={isDragging}
htmlId={`dashboard-tab-item-${tab.id}`}
dropPosition={
isDropTarget
? dropTarget.insertBefore
? 'before'
: 'after'
: undefined
}
{...tabItemProps}
/>
</div>
)
})}
</div>
<div className='dashboard-tabs-add-button-container'>
<Button
type='text'
className='dashboard-tabs-add-button electrobun-webkit-app-region-no-drag'
onClick={() => addTab()}
icon={<PlusIcon style={{ fontSize: 14, marginTop: 3 }} />}
/>
</div>
</Flex>
</ScrollBox>
<div
ref={lineRef}
className='dashboard-tabs-sticky-line'
aria-hidden='true'
/>
{activeTab ? (
<div
ref={cloneRef}
className='dashboard-tab-sticky-clone dashboard-tab-item-container dashboard-tab-item-container-active'
>
<DashboardTabItem
tab={activeTab}
tabIndex={activeTabIndex}
isSelected
isDragging={
draggedValue != null &&
String(draggedValue) === String(activeTab.id)
}
{...tabItemProps}
/>
</div>
) : null}
</Flex>
)
}
export default DashboardTabs

View File

@ -6,11 +6,13 @@ import MinusIcon from '../../Icons/MinusIcon'
import ContractIcon from '../../Icons/ContractIcon' import ContractIcon from '../../Icons/ContractIcon'
import ExpandIcon from '../../Icons/ExpandIcon' import ExpandIcon from '../../Icons/ExpandIcon'
import MacOSTrafficLights from './MacOSTrafficLights' import MacOSTrafficLights from './MacOSTrafficLights'
import { useThemeContext } from '../context/ThemeContext'
const DashboardWindowButtons = () => { const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform, isFullScreen } = const { isMaximized, handleWindowControl, platform, isFullScreen } =
useContext(ElectronContext) useContext(ElectronContext)
const { showNavigationLabels } = useThemeContext()
const closeButton = ( const closeButton = (
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
<MacOSTrafficLights /> <MacOSTrafficLights />
<Divider <Divider
type='vertical' type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }} style={{
height: '14px',
margin: showNavigationLabels ? '3px 6px 0 0' : '3px 0px 0 0'
}}
/> />
</> </>
) : ( ) : (

View File

@ -7,6 +7,8 @@ import { getModelByName } from '../../../database/ObjectModels'
import { getObjectIdFromSearch } from '../../../utils/modelActions' import { getObjectIdFromSearch } from '../../../utils/modelActions'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { NavigationTabActiveContext } from '../context/NavigationTabsContext'
import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage'
const ModelPage = ({ modelName, pageName }) => { const ModelPage = ({ modelName, pageName }) => {
const model = getModelByName(modelName) const model = getModelByName(modelName)
@ -15,17 +17,35 @@ const ModelPage = ({ modelName, pageName }) => {
const { setCurrentObject, setCurrentObjectType } = useActions() const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const objectId = getObjectIdFromSearch(modelName, location.search) const objectId = getObjectIdFromSearch(modelName, location.search)
const tabActiveStore = useContext(NavigationTabActiveContext)
useObjectNavigationTabPage({ modelName, pageName, objectId })
useEffect(() => { useEffect(() => {
setCurrentObjectType(modelName) const sync = () => {
if (objectId) { if (!tabActiveStore.getSnapshot()) return
setCurrentObject({ _id: objectId, _user: userProfile }) setCurrentObjectType(modelName)
if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile })
}
} }
sync()
const unsubscribe = tabActiveStore.subscribe(sync)
return () => { return () => {
setCurrentObject(null) unsubscribe()
setCurrentObjectType(null) if (tabActiveStore.getSnapshot()) {
setCurrentObject(null)
setCurrentObjectType(null)
}
} }
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile]) }, [
modelName,
objectId,
setCurrentObject,
setCurrentObjectType,
tabActiveStore,
userProfile
])
if (!page?.content) { if (!page?.content) {
return null return null

View File

@ -1,11 +1,11 @@
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd' import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { createElement } from 'react' import { createElement, memo } from 'react'
import Thumbnail from './Thumbnail' import Thumbnail from './Thumbnail'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
const ObjectCard = ({ const ObjectCard = memo(function ObjectCard({
isSkeleton = false, isSkeleton = false,
model, model,
modelProperties, modelProperties,
@ -16,7 +16,7 @@ const ObjectCard = ({
renderActions, renderActions,
lazyLoading = false, lazyLoading = false,
cardStyle = 'borderless' cardStyle = 'borderless'
}) => { }) {
const descriptionItems = [] const descriptionItems = []
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } }) const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
@ -169,7 +169,9 @@ const ObjectCard = ({
</Flex> </Flex>
</Card> </Card>
) )
} })
ObjectCard.displayName = 'ObjectCard'
ObjectCard.propTypes = { ObjectCard.propTypes = {
model: PropTypes.object.isRequired, model: PropTypes.object.isRequired,

View File

@ -2,11 +2,11 @@ import PropTypes from 'prop-types'
import { Typography, Flex, Badge, Tag, Popover } from 'antd' import { Typography, Flex, Badge, Tag, Popover } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import { useState, useEffect, useContext, useCallback, useRef } from 'react' import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions' import { findAction } from '../../../utils/modelActions'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useActions } from '../context/ActionsContext'
import merge from 'lodash/merge' import merge from 'lodash/merge'
import IdDisplay from './IdDisplay' import IdDisplay from './IdDisplay'
import ElipsisText from './ElipsisText' import ElipsisText from './ElipsisText'
@ -26,7 +26,7 @@ const ObjectDisplay = ({
const { subscribeToObjectUpdates, connected, fetchSpotlightData } = const { subscribeToObjectUpdates, connected, fetchSpotlightData } =
useContext(ApiServerContext) useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const navigate = useNavigate() const { callAction } = useActions()
const idRef = useRef(null) const idRef = useRef(null)
// Update event handler // Update event handler
@ -175,17 +175,9 @@ const ObjectDisplay = ({
const model = getModelByName(objectType) const model = getModelByName(objectType)
const Icon = model.icon const Icon = model.icon
const prefix = model.prefix const prefix = model.prefix
const infoAction = findAction(model, 'info')
// Get hyperlink URL from model's default actions
var hyperlink = null
const defaultModelActions =
model.actions?.filter((action) => action.default == true) || []
const objectId = getStringId(objectData) const objectId = getStringId(objectData)
if (defaultModelActions.length >= 1 && objectId) {
hyperlink = buildActionUrl(model, defaultModelActions[0], objectId, '', '')
}
// Render name with spotlight support // Render name with spotlight support
const renderNameDisplay = () => { const renderNameDisplay = () => {
if (!objectData?.name) return null if (!objectData?.name) return null
@ -215,7 +207,12 @@ const ObjectDisplay = ({
return textElement return textElement
} }
const canNavigate = showHyperlink && hyperlink != null const canNavigate = showHyperlink && infoAction != null && objectId
const handleNavigate = () => {
if (!canNavigate) return
callAction(infoAction, { ...objectData, _id: objectId }, objectType)
}
return ( return (
<Tag <Tag
@ -226,7 +223,7 @@ const ObjectDisplay = ({
cursor: canNavigate ? 'pointer' : undefined cursor: canNavigate ? 'pointer' : undefined
}} }}
className='object-display-tag' className='object-display-tag'
onClick={canNavigate ? () => navigate(hyperlink) : undefined} onClick={canNavigate ? handleNavigate : undefined}
> >
<Flex <Flex
gap={objectData?.color || objectData?.state ? 'small' : '5px'} gap={objectData?.color || objectData?.state ? 'small' : '5px'}

View File

@ -14,6 +14,7 @@ import PropTypes from 'prop-types'
import classNames from 'classnames' import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ObjectCard from './ObjectCard' import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn' import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader' import ObjectKanbanHeader from './ObjectKanbanHeader'
@ -287,6 +288,7 @@ const ObjectKanban = forwardRef(
subscribeToObjectTypeUpdates subscribeToObjectTypeUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const isTabActive = useIsNavigationTabActive()
const columnRefs = useRef({}) const columnRefs = useRef({})
const headerTrackRef = useRef(null) const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null) const skeletonTrackRef = useRef(null)
@ -720,7 +722,7 @@ const ObjectKanban = forwardRef(
}, [connected, token]) }, [connected, token])
useEffect(() => { useEffect(() => {
if (connected !== true || !type || !token) return if (connected !== true || !type || !token || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -735,10 +737,10 @@ const ObjectKanban = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, token]) }, [type, connected, subscribeToAllObjectUpdates, token, isTabActive])
useEffect(() => { useEffect(() => {
if (connected !== true || !token) return if (connected !== true || !token || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -757,7 +759,7 @@ const ObjectKanban = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, token]) }, [type, connected, token, isTabActive])
useImperativeHandle( useImperativeHandle(
ref, ref,

View File

@ -1,5 +1,6 @@
import { import {
forwardRef, forwardRef,
memo,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useEffect, useEffect,
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ActionsIcon from '../../Icons/ActionsIcon' import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5 const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {} const EMPTY_MASTER_FILTER = {}
const EMPTY_VISIBLE_COLUMNS = {}
const getCardColSpan = (containerWidth) => { const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2 if (containerWidth >= 2980) return 2
@ -291,8 +294,9 @@ EditableRow.propTypes = {
onRegister: PropTypes.func onRegister: PropTypes.func
} }
const ObjectTable = forwardRef( const ObjectTable = memo(
( forwardRef(
(
{ {
type, type,
pageSize = 25, pageSize = 25,
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
initialPage = 1, initialPage = 1,
viewMode: viewModeProp, viewMode: viewModeProp,
cards = false, cards = false,
visibleColumns = {}, visibleColumns = EMPTY_VISIBLE_COLUMNS,
masterFilter, masterFilter,
size = 'middle', size = 'middle',
onStateChange, onStateChange,
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
const { token, userProfile } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext) const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions() const { callAction } = useActions()
const isTabActive = useIsNavigationTabActive()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null const listViewFilter = objectListView?.listViewFilter ?? null
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
return {} return {}
}, []) }, [])
const rowActions = const rowActions = useMemo(
model.actions?.filter((action) => action.row == true) || [] () => model.actions?.filter((action) => action.row == true) || [],
[model]
)
const renderActions = useCallback(
(objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
},
[callAction, model, onRowAction, rowActions, type, userProfile]
)
const createSkeletonData = useCallback( const createSkeletonData = useCallback(
(pageNum) => { (pageNum) => {
@ -602,61 +667,6 @@ const ObjectTable = forwardRef(
) )
}, []) }, [])
const renderActions = (objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
}
const fetchPage = useCallback( const fetchPage = useCallback(
async (pageNum = 1, filter = null, sorter = null) => { async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) { if (filter == null) {
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
// Subscribe to all object updates for this type (list/cards only) // Subscribe to all object updates for this type (list/cards only)
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true || !type) return if (isKanban || connected !== true || !type || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, isKanban]) }, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true) return if (isKanban || connected !== true || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, isKanban]) }, [type, connected, isKanban, isTabActive])
const updateData = useCallback( const updateData = useCallback(
(id, updatedData) => { (id, updatedData) => {
@ -1706,27 +1716,30 @@ const ObjectTable = forwardRef(
return () => registerPageSorter({}) return () => registerPageSorter({})
}, [effectiveSorter, registerPageSorter, registerObjectListSorter]) }, [effectiveSorter, registerPageSorter, registerObjectListSorter])
const getFilterDropdown = ({ const getFilterDropdown = useCallback(
setSelectedKeys, ({
selectedKeys, setSelectedKeys,
confirm, selectedKeys,
clearFilters, confirm,
visible, clearFilters,
propertyName, visible,
propertyLabel propertyName,
}) => ( propertyLabel
<ColumnFilterDropdown }) => (
setSelectedKeys={setSelectedKeys} <ColumnFilterDropdown
selectedKeys={selectedKeys} setSelectedKeys={setSelectedKeys}
confirm={confirm} selectedKeys={selectedKeys}
clearFilters={clearFilters} confirm={confirm}
visible={visible} clearFilters={clearFilters}
propertyName={propertyName} visible={visible}
propertyLabel={propertyLabel} propertyName={propertyName}
modelType={type} propertyLabel={propertyLabel}
filter={effectiveFilter} modelType={type}
masterFilter={resolvedMasterFilter} filter={effectiveFilter}
/> masterFilter={resolvedMasterFilter}
/>
),
[effectiveFilter, resolvedMasterFilter, type]
) )
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
@ -1765,7 +1778,8 @@ const ObjectTable = forwardRef(
} }
} }
} else { } else {
// All tab: persist personal filter/sort to URL/session // All tab: persist personal filter/sort (session/URL, or this
// Electron tab's listState JSON when isElectron).
persistTableState(next, nextSorter) persistTableState(next, nextSorter)
} }
const effective = buildEffectiveFilter(next) const effective = buildEffectiveFilter(next)
@ -1858,66 +1872,7 @@ const ObjectTable = forwardRef(
] ]
) )
const modelProperties = getModelProperties(type) const modelProperties = useMemo(() => getModelProperties(type), [type])
// Table columns from model properties
const columnsWithSkeleton = [
{
title:
loading || lazyLoading ? (
<div style={{ marginLeft: 7 }}>
<LoadingOutlined style={{ fontSize: 14 }} />
</div>
) : canBulkSelect ? (
<div style={{ marginLeft: 7 }}>
<Checkbox
checked={bulkSelection?.allMatching === true}
indeterminate={
!bulkSelection?.allMatching && selectedRowIds.length > 0
}
onChange={(event) => toggleSelectAll(event.target.checked)}
aria-label={`Select all ${model.labelPlural || model.label}`}
/>
</div>
) : isCards ? (
model.icon
) : null,
key: 'icon',
width: 45,
fixed: 'left',
render: (_, record) => {
const selected =
bulkSelection?.allMatching === true ||
selectedRowIdSet.has(String(record?._id))
if (!canBulkSelect || record?.isSkeleton) {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
}
return (
<Flex justify='center'>
<div
className={classNames('object-table-select-cell', {
'object-table-select-cell-selected': selected
})}
>
<Checkbox
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
toggleRowSelection(record, event.target.checked)
}
className='object-table-row-checkbox'
aria-label={`Select ${record?.name || record?._reference || record?._id}`}
/>
<div className='object-table-row-icon'>
{createElement(model.icon)}
</div>
</div>
</Flex>
)
}
}
]
useEffect(() => { useEffect(() => {
pagesRef.current = pages pagesRef.current = pages
@ -1954,131 +1909,211 @@ const ObjectTable = forwardRef(
tableData tableData
]) ])
// Add columns in the order specified by model.columns const hasRealTableRows = tableData.some((item) => !item?.isSkeleton)
model.columns.forEach((colName) => {
const prop = modelProperties.find((p) => p.name === colName)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
}
var fixed = prop.columnFixed || undefined const columnsWithSkeleton = useMemo(() => {
var width = 200 const columns = [
{
switch (prop.type) { title:
case 'text': loading || lazyLoading ? (
width = 200 <div style={{ marginLeft: 7 }}>
break <LoadingOutlined style={{ fontSize: 14 }} />
case 'number': </div>
width = 100 ) : canBulkSelect ? (
break <div style={{ marginLeft: 7 }}>
case 'dateTime': <Checkbox
width = 200 checked={bulkSelection?.allMatching === true}
break indeterminate={
case 'state': !bulkSelection?.allMatching && selectedRowIds.length > 0
width = 200 }
break onChange={(event) => toggleSelectAll(event.target.checked)}
case 'id': aria-label={`Select all ${model.labelPlural || model.label}`}
width = 180
break
default:
break
}
// Check if this property should be filterable based on model.filters
const isFilterable = model.filters && model.filters.includes(prop.name)
// Check if this property should be sortable based on model.sorters
const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = {
sorter: isSortable ? { multiple: 1 } : undefined,
sortOrder:
tableSorter?.field === prop.name ? tableSorter.order : null,
title: prop.label,
width: prop.columnWidth || width,
fixed: isMobile ? undefined : fixed,
key: prop.name,
filterIcon: () => {
return (
<Tooltip title='Filter' listenParents={1}>
<FilterIcon />
</Tooltip>
)
},
render: (text, record) => {
if (record?.isSkeleton) {
return (
<Skeleton.Input
active
size='small'
style={{ width: '100%', height: 24.5 }}
/> />
) </div>
) : isCards ? (
model.icon
) : null,
key: 'icon',
width: 45,
fixed: 'left',
render: (_, record) => {
const selected =
bulkSelection?.allMatching === true ||
selectedRowIdSet.has(String(record?._id))
if (!canBulkSelect || record?.isSkeleton) {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
} }
return ( return (
<ObjectProperty <Flex justify='center'>
{...prop} <div
longId={false} className={classNames('object-table-select-cell', {
inTable={true} 'object-table-select-cell-selected': selected
objectData={record} })}
isEditing={isEditing} >
/> <Checkbox
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
toggleRowSelection(record, event.target.checked)
}
className='object-table-row-checkbox'
aria-label={`Select ${record?.name || record?._reference || record?._id}`}
/>
<div className='object-table-row-icon'>
{createElement(model.icon)}
</div>
</div>
</Flex>
) )
} }
} }
]
if ( model.columns.forEach((colName) => {
isFilterable && const prop = modelProperties.find((p) => p.name === colName)
!Object.keys(resolvedMasterFilter).includes(prop.name) if (prop) {
) { if (
columnConfig.filterDropdown = ({ Object.keys(visibleColumns).length > 0 &&
setSelectedKeys, visibleColumns[prop.name] === false
selectedKeys, ) {
confirm, return
clearFilters, }
visible
}) => var fixed = prop.columnFixed || undefined
getFilterDropdown({ var width = 200
switch (prop.type) {
case 'text':
width = 200
break
case 'number':
width = 100
break
case 'dateTime':
width = 200
break
case 'state':
width = 200
break
case 'id':
width = 180
break
default:
break
}
const isFilterable = model.filters && model.filters.includes(prop.name)
const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = {
sorter: isSortable ? { multiple: 1 } : undefined,
sortOrder:
tableSorter?.field === prop.name ? tableSorter.order : null,
title: prop.label,
width: prop.columnWidth || width,
fixed: isMobile ? undefined : fixed,
key: prop.name,
filterIcon: () => {
return (
<Tooltip title='Filter' listenParents={1}>
<FilterIcon />
</Tooltip>
)
},
render: (text, record) => {
if (record?.isSkeleton) {
return (
<Skeleton.Input
active
size='small'
style={{ width: '100%', height: 24.5 }}
/>
)
}
return (
<ObjectProperty
{...prop}
longId={false}
inTable={true}
objectData={record}
isEditing={isEditing}
/>
)
}
}
if (
isFilterable &&
!Object.keys(resolvedMasterFilter).includes(prop.name)
) {
columnConfig.filterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
confirm, confirm,
clearFilters, clearFilters,
visible, visible
propertyName: prop.name, }) =>
propertyLabel: prop.label getFilterDropdown({
}) setSelectedKeys,
columnConfig.filteredValue = fromFilterExpression( selectedKeys,
effectiveFilter[prop.name] confirm,
) clearFilters,
} visible,
propertyName: prop.name,
propertyLabel: prop.label
})
columnConfig.filteredValue = fromFilterExpression(
effectiveFilter[prop.name]
)
}
columnsWithSkeleton.push(columnConfig) columns.push(columnConfig)
}
})
if (
showActions &&
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
</Flex>
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
render: (record) => {
return renderActions(record)
} }
}) })
}
if (showActions && rowActions.length > 0 && hasRealTableRows) {
columns.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
</Flex>
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30,
render: (record) => {
return renderActions(record)
}
})
}
return columns
}, [
bulkSelection,
canBulkSelect,
effectiveFilter,
getFilterDropdown,
hasRealTableRows,
isCards,
isEditing,
isMobile,
lazyLoading,
loading,
model,
modelProperties,
renderActions,
resolvedMasterFilter,
rowActions,
selectedRowIdSet,
selectedRowIds,
showActions,
tableSorter,
toggleRowSelection,
toggleSelectAll,
visibleColumns
])
// Card view rendering // Card view rendering
const [cardColSpan, setCardColSpan] = useState(24) const [cardColSpan, setCardColSpan] = useState(24)
@ -2465,6 +2500,7 @@ const ObjectTable = forwardRef(
</ObjectTableFilterContext.Provider> </ObjectTableFilterContext.Provider>
) )
} }
)
) )
ObjectTable.displayName = 'ObjectTable' ObjectTable.displayName = 'ObjectTable'

View File

@ -9,6 +9,7 @@ import {
} from 'react' } from 'react'
import { HolderOutlined } from '@ant-design/icons' import { HolderOutlined } from '@ant-design/icons'
import classNames from 'classnames' import classNames from 'classnames'
import { hasExternalTabDrag } from './tabDrag'
const SegmentedNavReorderContext = createContext(null) const SegmentedNavReorderContext = createContext(null)
@ -59,6 +60,9 @@ const SegmentedNav = ({
options = [], options = [],
onChange, onChange,
onReorder, onReorder,
onExternalDrop,
onItemDragStart,
onItemDragEnd,
className, className,
disabled = false, disabled = false,
animated = true animated = true
@ -75,7 +79,10 @@ const SegmentedNav = ({
const [dropTarget, setDropTarget] = useState(null) const [dropTarget, setDropTarget] = useState(null)
const enabledOptions = getEnabledOptions(options, disabled) const enabledOptions = getEnabledOptions(options, disabled)
const isReordering = Boolean(onReorder && draggedValue != null) const acceptsExternal = Boolean(onExternalDrop)
const isReordering = Boolean(
(onReorder || acceptsExternal) && (draggedValue != null || dropTarget)
)
const setItemRef = useCallback((optionValue, element) => { const setItemRef = useCallback((optionValue, element) => {
if (element) { if (element) {
@ -172,21 +179,32 @@ const SegmentedNav = ({
setDraggedValue(optionValue) setDraggedValue(optionValue)
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(optionValue)) if (!onItemDragStart) {
event.dataTransfer.setData('text/plain', String(optionValue))
}
onItemDragStart?.(event, optionValue)
}, },
[onReorder] [onItemDragStart, onReorder]
) )
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
onItemDragEnd?.()
clearDragState() clearDragState()
}, [clearDragState]) }, [clearDragState, onItemDragEnd])
const handleItemDragOver = useCallback( const handleItemDragOver = useCallback(
(event, option) => { (event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) { const isExternal = acceptsExternal && hasExternalTabDrag(event)
if ((!onReorder && !isExternal) || !option.reorderable) {
return return
} }
if (String(option.value) === String(draggedValue)) { if (draggedValue == null && !isExternal) {
return
}
if (
draggedValue != null &&
String(option.value) === String(draggedValue)
) {
return return
} }
@ -200,12 +218,13 @@ const SegmentedNav = ({
insertBefore: event.clientX < rect.left + rect.width / 2 insertBefore: event.clientX < rect.left + rect.width / 2
}) })
}, },
[draggedValue, onReorder] [acceptsExternal, draggedValue, onReorder]
) )
const handleItemDrop = useCallback( const handleItemDrop = useCallback(
(event, option) => { (event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) { const isExternal = acceptsExternal && draggedValue == null
if ((!onReorder && !isExternal) || !option.reorderable) {
return return
} }
@ -217,18 +236,39 @@ const SegmentedNav = ({
insertBefore: true insertBefore: true
} }
if (isExternal) {
onExternalDrop?.(target.value, target.insertBefore, event)
clearDragState()
return
}
if (draggedValue == null) {
return
}
if (String(draggedValue) !== String(target.value)) { if (String(draggedValue) !== String(target.value)) {
onReorder(draggedValue, target.value, target.insertBefore) onReorder(draggedValue, target.value, target.insertBefore)
} }
clearDragState() clearDragState()
}, },
[clearDragState, draggedValue, dropTarget, onReorder] [
acceptsExternal,
clearDragState,
draggedValue,
dropTarget,
onExternalDrop,
onReorder
]
) )
const handleGroupDragOver = useCallback( const handleGroupDragOver = useCallback(
(event) => { (event) => {
if (!onReorder || draggedValue == null) { const isExternal = acceptsExternal && hasExternalTabDrag(event)
if (
(!onReorder && !isExternal) ||
(draggedValue == null && !isExternal)
) {
return return
} }
@ -253,12 +293,16 @@ const SegmentedNav = ({
insertBefore: false insertBefore: false
}) })
}, },
[draggedValue, onReorder, options] [acceptsExternal, draggedValue, onReorder, options]
) )
const handleGroupDrop = useCallback( const handleGroupDrop = useCallback(
(event) => { (event) => {
if (!onReorder || draggedValue == null) { const isExternal = acceptsExternal && draggedValue == null
if (
(!onReorder && !isExternal) ||
(draggedValue == null && !isExternal)
) {
return return
} }
@ -274,13 +318,31 @@ const SegmentedNav = ({
const reorderableOptions = options.filter((option) => option.reorderable) const reorderableOptions = options.filter((option) => option.reorderable)
const lastOption = reorderableOptions[reorderableOptions.length - 1] const lastOption = reorderableOptions[reorderableOptions.length - 1]
if (lastOption && String(draggedValue) !== String(lastOption.value)) { if (!lastOption) {
clearDragState()
return
}
if (isExternal) {
onExternalDrop?.(lastOption.value, false, event)
clearDragState()
return
}
if (String(draggedValue) !== String(lastOption.value)) {
onReorder(draggedValue, lastOption.value, false) onReorder(draggedValue, lastOption.value, false)
} }
clearDragState() clearDragState()
}, },
[clearDragState, draggedValue, onReorder, options] [
acceptsExternal,
clearDragState,
draggedValue,
onExternalDrop,
onReorder,
options
]
) )
const getDragHandleProps = useCallback( const getDragHandleProps = useCallback(
@ -381,6 +443,13 @@ const SegmentedNav = ({
isDropTarget && !dropTarget.insertBefore, isDropTarget && !dropTarget.insertBefore,
'segmented-nav-item-icon': option.icon 'segmented-nav-item-icon': option.icon
})} })}
draggable={Boolean(onReorder && option.reorderable)}
onDragStart={
option.reorderable
? (event) => handleDragStart(event, option.value)
: undefined
}
onDragEnd={option.reorderable ? handleDragEnd : undefined}
onClick={() => { onClick={() => {
if (!isDisabled) { if (!isDisabled) {
onChange?.(option.value) onChange?.(option.value)
@ -431,6 +500,9 @@ SegmentedNav.propTypes = {
), ),
onChange: PropTypes.func, onChange: PropTypes.func,
onReorder: PropTypes.func, onReorder: PropTypes.func,
onExternalDrop: PropTypes.func,
onItemDragStart: PropTypes.func,
onItemDragEnd: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
animated: PropTypes.bool animated: PropTypes.bool

View File

@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
import { useAppUpdateContext } from '../context/AppUpdateContext' import { useAppUpdateContext } from '../context/AppUpdateContext'
import { getSidebarMenuSections } from '../../../database/Sidebars' import { getSidebarMenuSections } from '../../../database/Sidebars'
import KeyboardShortcut from './KeyboardShortcut'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall' import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
const runEditCommand = (command) => { const runEditCommand = (command) => {
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const { handleWindowControl } = useContext(ElectronContext) const { handleWindowControl } = useContext(ElectronContext)
const { addTab, createNewWindow } = useNavigationTabs()
const { checkForUpdates } = useAppUpdateContext() const { checkForUpdates } = useAppUpdateContext()
const includeDev = import.meta.env.DEV const includeDev = import.meta.env.DEV
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
const fileMenuItems = useMemo( const fileMenuItems = useMemo(
() => [ () => [
{
key: 'new-window',
label: 'New Window',
onClick: () => createNewWindow()
},
{
key: 'new-tab',
label: 'New Tab',
onClick: () => addTab()
},
{ type: 'divider' },
{ {
key: 'close', key: 'close',
label: 'Close Window', label: 'Close Window',
onClick: () => handleWindowControl('close') onClick: () => handleWindowControl('close')
} }
], ],
[handleWindowControl] [addTab, createNewWindow, handleWindowControl]
) )
const editMenuItems = useMemo( const editMenuItems = useMemo(
@ -250,6 +264,19 @@ const WindowAppMenu = () => {
/> />
) )
} }
if (menu.key === 'file') {
return (
<KeyboardShortcut
key={menu.key}
shortcut='ctrl+t'
onTrigger={addTab}
>
<KeyboardShortcut shortcut='ctrl+n' onTrigger={createNewWindow}>
<MenuButton label={menu.label} items={menu.items} />
</KeyboardShortcut>
</KeyboardShortcut>
)
}
return ( return (
<MenuButton key={menu.key} label={menu.label} items={menu.items} /> <MenuButton key={menu.key} label={menu.label} items={menu.items} />
) )

View File

@ -0,0 +1,59 @@
export const TAB_DRAG_MIME = 'application/x-farmcontrol-tab'
export const TAB_DRAG_TEXT_PREFIX = 'farmcontrol-tab:'
export const hasExternalTabDrag = (event) => {
const types = event?.dataTransfer?.types
if (!types) return false
const list = Array.from(types)
return (
list.includes(TAB_DRAG_MIME) ||
list.includes('text/plain') ||
list.includes('text/uri-list')
)
}
export const writeTabDragData = (event, payload) => {
const serialized = JSON.stringify(payload)
try {
event.dataTransfer.setData(TAB_DRAG_MIME, serialized)
} catch {
// Some engines only allow text/plain during dragstart.
}
event.dataTransfer.setData('text/plain', `${TAB_DRAG_TEXT_PREFIX}${serialized}`)
}
export const readTabDragData = (event) => {
const transfer = event?.dataTransfer
if (!transfer) return null
const custom = (() => {
try {
return transfer.getData(TAB_DRAG_MIME)
} catch {
return ''
}
})()
const plain = (() => {
try {
return transfer.getData('text/plain')
} catch {
return ''
}
})()
const raw = custom
? custom
: plain.startsWith(TAB_DRAG_TEXT_PREFIX)
? plain.slice(TAB_DRAG_TEXT_PREFIX.length)
: ''
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (!parsed?.tab?.id) return null
return parsed
} catch {
return null
}
}

View File

@ -4,6 +4,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -20,52 +21,102 @@ import {
stripModalActionParams stripModalActionParams
} from '../../../utils/modelActions' } from '../../../utils/modelActions'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
import { useNavigationTabs } from './NavigationTabsContext'
const ActionsContext = createContext() const ActionsContext = createContext()
const ActionsProvider = ({ children }) => { const ActionsProvider = ({ children }) => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const locationRef = useRef(location)
locationRef.current = location
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null) const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null) const [currentObjectType, setCurrentObjectType] = useState(null)
const [modalAction, setModalAction] = useState(null) const [modalAction, setModalAction] = useState(null)
const [onModalOk, setOnModalOk] = useState(null) const [onModalOk, setOnModalOk] = useState(null)
const lastHandledAction = useRef(null) const lastHandledAction = useRef(null)
const { isElectron } = useContext(ElectronContext)
const { addTab } = useNavigationTabs()
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
const actionName = searchParams.get('action') const actionName = searchParams.get('action')
const actionObjectType = searchParams.get('actionObjectType') const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => { const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search) const loc = locationRef.current
if (nextUrl !== location.pathname + location.search) { const nextUrl = stripModalActionParams(loc.pathname, loc.search)
if (nextUrl !== loc.pathname + loc.search) {
navigate(nextUrl, { replace: true }) navigate(nextUrl, { replace: true })
} }
setModalAction(null) setModalAction(null)
lastHandledAction.current = null lastHandledAction.current = null
}, [location.pathname, location.search, navigate]) }, [navigate])
const handleModalOk = useCallback(() => { const handleModalOk = useCallback(() => {
onModalOk?.() onModalOk?.()
clearAction() clearAction()
}, [clearAction, onModalOk]) }, [clearAction, onModalOk])
const [ctrlDown, setCtrlDown] = useState(false)
useEffect(() => {
const isHoldKey = (key) => key === 'Meta' || key === 'Control'
const handleKeyDown = (event) => {
if (isHoldKey(event.key)) {
setCtrlDown(true)
}
}
const handleKeyUp = (event) => {
if (isHoldKey(event.key)) {
setCtrlDown(false)
}
}
const clearHold = () => setCtrlDown(false)
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', clearHold)
document.addEventListener('visibilitychange', clearHold)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', clearHold)
document.removeEventListener('visibilitychange', clearHold)
}
}, [])
const handleNavigate = useCallback(
(url) => {
if (isElectron && ctrlDown) {
addTab(url)
return
}
if (!isElectron && ctrlDown) {
window.open(url, '_blank')
return
}
navigate(url, { replace: true })
},
[addTab, navigate, ctrlDown, isElectron]
)
const callAction = useCallback( const callAction = useCallback(
(action, objectData, objectType) => { (action, objectData, objectType) => {
setCurrentObject(objectData) setCurrentObject(objectData)
setCurrentObjectType(objectType) setCurrentObjectType(objectType)
const model = getModelByName(objectType) const model = getModelByName(objectType)
const loc = locationRef.current
const url = buildActionUrl( const url = buildActionUrl(
model, model,
action, action,
objectData._id, objectData._id,
location.pathname, loc.pathname,
location.search loc.search
) )
navigate(url) handleNavigate(url)
}, },
[location.pathname, location.search, navigate] [handleNavigate]
) )
useEffect(() => { useEffect(() => {
@ -123,7 +174,7 @@ const ActionsProvider = ({ children }) => {
if (action.name === 'list') { if (action.name === 'list') {
lastHandledAction.current = actionKey lastHandledAction.current = actionKey
if (location.pathname !== model.url) { if (location.pathname !== model.url) {
navigate(model.url, { replace: true }) handleNavigate(model.url)
} }
return return
} }
@ -147,7 +198,7 @@ const ActionsProvider = ({ children }) => {
location.pathname, location.pathname,
location.search location.search
) )
navigate(url, { replace: true }) handleNavigate(url)
} }
}, [ }, [
actionName, actionName,
@ -155,7 +206,7 @@ const ActionsProvider = ({ children }) => {
currentObject, currentObject,
location.pathname, location.pathname,
location.search, location.search,
navigate handleNavigate
]) ])
const modalObjectData = currentObject const modalObjectData = currentObject
@ -187,18 +238,22 @@ const ActionsProvider = ({ children }) => {
? {} ? {}
: modalObjectData : modalObjectData
const value = useMemo(
() => ({
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk,
ctrlDown
}),
[callAction, clearAction, currentObject, currentObjectType, ctrlDown]
)
return ( return (
<ActionsContext.Provider <ActionsContext.Provider value={value}>
value={{
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk
}}
>
<Modal <Modal
open={resolvedModalAction != null} open={resolvedModalAction != null}
destroyOnHidden={true} destroyOnHidden={true}

View File

@ -5,7 +5,8 @@ import {
useState, useState,
useContext, useContext,
useRef, useRef,
useCallback useCallback,
useMemo
} from 'react' } from 'react'
import io from 'socket.io-client' import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography, Flex } from 'antd' import { message, Modal, Space, Button, Typography, Flex } from 'antd'
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
const getObjectEndpoint = (type) => const getObjectEndpoint = (type) =>
getModelByName(type)?.endpoint || `${type.toLowerCase()}s` getModelByName(type)?.endpoint || `${type.toLowerCase()}s`
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
const normalized = cleaned.trim().replace(/\s+/g, '_')
return normalized.slice(0, 255)
}
const API_METHOD_KEYS = [
'getUserSettings',
'updateUserSettings',
'setObjectActivity',
'clearObjectActivity',
'fetchObjectActivities',
'updateObject',
'updateMultipleObjects',
'createObject',
'getObjectFunction',
'sendObjectFunction',
'deleteObject',
'deleteObjects',
'subscribeToObjectUpdates',
'subscribeToAllObjectUpdates',
'subscribeToObjectEvent',
'subscribeToObjectTypeUpdates',
'subscribeToObjectActivity',
'subscribeToModelStats',
'fetchObject',
'fetchObjects',
'getObjectNeighbors',
'fetchObjectsByProperty',
'searchObjects',
'fetchSpotlightData',
'getModelStats',
'getModelPropertyValues',
'getModelHistory',
'showError',
'fetchFileContent',
'fetchFileThumbnail',
'exportToExcel',
'exportToCsv',
'fetchTemplatePreview',
'fetchTemplateIntellisense',
'fetchTemplateFormat',
'fetchTemplatePDF',
'fetchTemplateDownload',
'fetchNotes',
'downloadTemplate',
'downloadTemplatePDF',
'fetchHostOTP',
'sendObjectAction',
'uploadFile',
'flushFile',
'formatFileName',
'createUserNotifier',
'deleteUserNotifier',
'fetchUserNotifiersForObject',
'fetchAllUserNotifiersForObject',
'toggleUserNotifier',
'editUserNotifier',
'fetchObjectViews',
'createObjectView',
'updateObjectView',
'deleteObjectView',
'fetchNotificationsApi',
'markNotificationAsReadApi',
'markAllNotificationsAsReadApi',
'deleteNotificationApi',
'deleteAllNotificationsApi',
'registerNotificationListener',
'unregisterNotificationListener',
'getMarketplaceAuthUrl',
'refreshMarketplaceAuth',
'completeAppLaunchSession',
'getAppLaunchSession',
'fetchAppUpdateBranches',
'fetchAppUpdateCurrent',
'fetchWsServerVersion',
'fetchApiServerVersion'
]
const createStableApiMethods = (methodsRef) => {
const bound = {}
for (const key of API_METHOD_KEYS) {
bound[key] = (...args) => methodsRef.current[key]?.(...args)
}
return bound
}
const LaunchSessionCompleter = ({ completeAppLaunchSession }) => {
const location = useLocation()
const navigate = useNavigate()
const { token, authenticated } = useContext(AuthContext)
const completedLaunchSessionsRef = useRef(new Set())
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
return null
}
LaunchSessionCompleter.propTypes = {
completeAppLaunchSession: PropTypes.func.isRequired
}
const ConnectionIssueModal = ({
open,
delayMs,
cycle,
isReconnecting,
onReconnect
}) => {
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(delayMs)
const [reconnectProgress, setReconnectProgress] = useState(0)
useEffect(() => {
if (!open || isReconnecting) return undefined
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
const intervalId = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
return () => clearInterval(intervalId)
}, [open, isReconnecting, delayMs, cycle])
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
return (
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={open}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button key='reconnect' loading={isReconnecting} onClick={onReconnect}>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{`Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
)
}
ConnectionIssueModal.propTypes = {
open: PropTypes.bool,
delayMs: PropTypes.number,
cycle: PropTypes.number,
isReconnecting: PropTypes.bool,
onReconnect: PropTypes.func
}
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
token, token,
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityCallbacksRef = useRef(new Map()) const subscribedActivityCallbacksRef = useRef(new Map())
const subscribedActivityServerSubscriptionsRef = useRef(new Set()) const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false) const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0) const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
const [reconnectRemainingMs, setReconnectRemainingMs] = useState( const [reconnectCycle, setReconnectCycle] = useState(0)
RECONNECT_DELAYS_MS[0]
)
const [isReconnecting, setIsReconnecting] = useState(false) const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0) const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null) const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false) const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false) const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false) const hasConnectedOnceRef = useRef(false)
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
clearTimeout(reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null reconnectTimerRef.current = null
} }
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, []) }, [])
const resetReconnectState = useCallback(() => { const resetReconnectState = useCallback(() => {
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
reconnectScheduledRef.current = false reconnectScheduledRef.current = false
isReconnectingRef.current = false isReconnectingRef.current = false
setIsReconnecting(false) setIsReconnecting(false)
setReconnectProgress(0) setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers]) }, [clearReconnectTimers])
const startReconnectCountdown = useCallback( const startReconnectCountdown = useCallback(
(delayMs) => { (delayMs) => {
clearReconnectTimers() clearReconnectTimers()
const startedAt = Date.now() setReconnectDelayMs(delayMs)
setReconnectRemainingMs(delayMs) setReconnectCycle((current) => current + 1)
setReconnectProgress(0)
setIsReconnecting(false) setIsReconnecting(false)
reconnectProgressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = setTimeout(() => {
attemptReconnectRef.current?.() attemptReconnectRef.current?.()
}, delayMs) }, delayMs)
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
isReconnectingRef.current = true isReconnectingRef.current = true
reconnectAttemptRef.current += 1 reconnectAttemptRef.current += 1
setIsReconnecting(true) setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true) setConnecting(true)
setConnectionIssue(true) setConnectionIssue(true)
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
[token] [token]
) )
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
const getAppLaunchSession = useCallback(async (launchSession) => { const getAppLaunchSession = useCallback(async (launchSession) => {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/applaunch/${launchSession}`, `${config.backendUrl}/applaunch/${launchSession}`,
@ -2924,167 +3104,120 @@ const ApiServerProvider = ({ children }) => {
} }
} }
const reconnectSecondsRemaining = Math.max( const apiMethodsRef = useRef({})
1, apiMethodsRef.current = {
Math.ceil(reconnectRemainingMs / 1000) getUserSettings,
) updateUserSettings,
setObjectActivity,
// Sanitize a string so it is safe to use as a filename on most file systems clearObjectActivity,
const formatFileName = (name) => { fetchObjectActivities,
if (!name || typeof name !== 'string') { updateObject,
return '' updateMultipleObjects,
} createObject,
getObjectFunction,
// Remove characters that are problematic on most common file systems sendObjectFunction,
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '') deleteObject,
deleteObjects,
// Normalize whitespace to single underscores subscribeToObjectUpdates,
const normalized = cleaned.trim().replace(/\s+/g, '_') subscribeToAllObjectUpdates,
subscribeToObjectEvent,
// Most file systems limit filenames to 255 characters subscribeToObjectTypeUpdates,
return normalized.slice(0, 255) subscribeToObjectActivity,
subscribeToModelStats,
fetchObject,
fetchObjects,
getObjectNeighbors,
fetchObjectsByProperty,
searchObjects,
fetchSpotlightData,
getModelStats,
getModelPropertyValues,
getModelHistory,
showError,
fetchFileContent,
fetchFileThumbnail,
exportToExcel,
exportToCsv,
fetchTemplatePreview,
fetchTemplateIntellisense,
fetchTemplateFormat,
fetchTemplatePDF,
fetchTemplateDownload,
fetchNotes,
downloadTemplate,
downloadTemplatePDF,
fetchHostOTP,
sendObjectAction,
uploadFile,
flushFile,
formatFileName,
createUserNotifier,
deleteUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject,
toggleUserNotifier,
editUserNotifier,
fetchObjectViews,
createObjectView,
updateObjectView,
deleteObjectView,
fetchNotificationsApi,
markNotificationAsReadApi,
markAllNotificationsAsReadApi,
deleteNotificationApi,
deleteAllNotificationsApi,
registerNotificationListener,
unregisterNotificationListener,
getMarketplaceAuthUrl,
refreshMarketplaceAuth,
completeAppLaunchSession,
getAppLaunchSession,
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
} }
const stableApiRef = useRef(null)
if (stableApiRef.current == null) {
stableApiRef.current = createStableApiMethods(apiMethodsRef)
}
const value = useMemo(
() => ({
...stableApiRef.current,
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
fetchLoading
}),
[
connecting,
connected,
error,
fetchLoading,
userSettings,
userSettingsLoaded
]
)
return ( return (
<ApiServerContext.Provider <ApiServerContext.Provider value={value}>
value={{
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
getUserSettings,
updateUserSettings,
setObjectActivity,
clearObjectActivity,
fetchObjectActivities,
updateObject,
updateMultipleObjects,
createObject,
getObjectFunction,
sendObjectFunction,
deleteObject,
deleteObjects,
subscribeToObjectUpdates,
subscribeToAllObjectUpdates,
subscribeToObjectEvent,
subscribeToObjectTypeUpdates,
subscribeToObjectActivity,
subscribeToModelStats,
fetchObject,
fetchObjects,
getObjectNeighbors,
fetchObjectsByProperty,
searchObjects,
fetchSpotlightData,
getModelStats,
getModelPropertyValues,
getModelHistory,
fetchLoading,
showError,
fetchFileContent,
fetchFileThumbnail,
exportToExcel,
exportToCsv,
fetchTemplatePreview,
fetchTemplateIntellisense,
fetchTemplateFormat,
fetchTemplatePDF,
fetchTemplateDownload,
fetchNotes,
downloadTemplate,
downloadTemplatePDF,
fetchHostOTP,
sendObjectAction,
uploadFile,
flushFile,
formatFileName,
createUserNotifier,
deleteUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject,
toggleUserNotifier,
editUserNotifier,
fetchObjectViews,
createObjectView,
updateObjectView,
deleteObjectView,
fetchNotificationsApi,
markNotificationAsReadApi,
markAllNotificationsAsReadApi,
deleteNotificationApi,
deleteAllNotificationsApi,
registerNotificationListener,
unregisterNotificationListener,
getMarketplaceAuthUrl,
refreshMarketplaceAuth,
completeAppLaunchSession,
getAppLaunchSession,
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
}}
>
{contextHolder} {contextHolder}
<LaunchSessionCompleter
completeAppLaunchSession={completeAppLaunchSession}
/>
{children} {children}
<Modal <ConnectionIssueModal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={Boolean(token) && authenticated == true && connectionIssue} open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: !isReconnecting ? 480 : 260 }} delayMs={reconnectDelayMs}
zIndex={3000} cycle={reconnectCycle}
closable={false} isReconnecting={isReconnecting}
height={isReconnecting ? 20 : undefined} onReconnect={attemptReconnect}
centered />
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal <Modal
title={ title={
<Space size={'middle'}> <Space size={'middle'}>

View File

@ -5,7 +5,8 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useContext, useContext,
useRef useRef,
useMemo
} from 'react' } from 'react'
import axios from 'axios' import axios from 'axios'
import { import {
@ -112,6 +113,8 @@ const AuthProvider = ({ children }) => {
} = useContext(ElectronContext) } = useContext(ElectronContext)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const locationRef = useRef(location)
locationRef.current = location
const sessionRef = useRef({ const sessionRef = useRef({
token, token,
expiresAt, expiresAt,
@ -432,9 +435,11 @@ const AuthProvider = ({ children }) => {
// Login using query parameters // Login using query parameters
const loginWithSSO = useCallback( const loginWithSSO = useCallback(
(redirectUri = location.pathname + location.search) => { (redirectUri) => {
const loc = locationRef.current
const nextRedirectUri = redirectUri || loc.pathname + loc.search
messageApi.info('Logging in with tombutcher.work') messageApi.info('Logging in with tombutcher.work')
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}` const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(nextRedirectUri)}`
if (isElectron) { if (isElectron) {
logger.debug('Opening external url...') logger.debug('Opening external url...')
openExternalUrl(loginUrl) openExternalUrl(loginUrl)
@ -444,14 +449,7 @@ const AuthProvider = ({ children }) => {
window.location.href = loginUrl window.location.href = loginUrl
} }
}, },
[ [redirectType, messageApi, openExternalUrl, isElectron]
redirectType,
messageApi,
openExternalUrl,
isElectron,
location.search,
location.pathname
]
) )
const getLoginToken = useCallback( const getLoginToken = useCallback(
@ -461,7 +459,6 @@ const AuthProvider = ({ children }) => {
setShowSessionExpiredModal(false) setShowSessionExpiredModal(false)
setAuthError(null) setAuthError(null)
try { try {
// Make a call to your backend to check auth status
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/auth/${redirectType}/token?code=${code}` `${config.backendUrl}/auth/${redirectType}/token?code=${code}`
) )
@ -480,7 +477,6 @@ const AuthProvider = ({ children }) => {
setAuthenticated(true) setAuthenticated(true)
setShowUnauthorizedModal(false) setShowUnauthorizedModal(false)
// Persist session (cookies on web, electron storage on desktop)
const persisted = await persistSession({ const persisted = await persistSession({
token: nextToken, token: nextToken,
expiresAt: nextExpiresAt, expiresAt: nextExpiresAt,
@ -492,10 +488,11 @@ const AuthProvider = ({ children }) => {
) )
} }
const searchParams = new URLSearchParams(location.search) const loc = locationRef.current
const searchParams = new URLSearchParams(loc.search)
searchParams.delete('authCode') searchParams.delete('authCode')
const newSearch = searchParams.toString() const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '') const newPath = loc.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true }) navigate(newPath, { replace: true })
} else { } else {
setAuthenticated(false) setAuthenticated(false)
@ -520,14 +517,7 @@ const AuthProvider = ({ children }) => {
setRetreivedTokenFromCookies(true) setRetreivedTokenFromCookies(true)
} }
}, },
[ [navigate, messageApi, persistSession, redirectType]
navigate,
location.search,
location.pathname,
messageApi,
persistSession,
redirectType
]
) )
// Function to check if the user is logged in // Function to check if the user is logged in
@ -805,25 +795,39 @@ const AuthProvider = ({ children }) => {
retreivedTokenFromCookies retreivedTokenFromCookies
]) ])
const value = useMemo(
() => ({
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
setUserProfile,
profileImageUrl,
logout
}),
[
authenticated,
getLoginToken,
loading,
loginWithSSO,
logout,
profileImageUrl,
retreivedTokenFromCookies,
setUnauthenticated,
token,
userProfile
]
)
return ( return (
<> <>
{contextHolder} {contextHolder}
{notificationContextHolder} {notificationContextHolder}
<AuthContext.Provider <AuthContext.Provider value={value}>
value={{
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
setUserProfile,
profileImageUrl,
logout
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
<Modal <Modal

View File

@ -3,10 +3,12 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { NavigationTabActiveContext } from './NavigationTabsContext'
const DashboardObjectToolsContext = createContext() const DashboardObjectToolsContext = createContext()
@ -27,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
setCurrentObjectToolsState(null) setCurrentObjectToolsState(null)
}, []) }, [])
const value = useMemo(
() => ({
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}),
[clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
)
return ( return (
<DashboardObjectToolsContext.Provider <DashboardObjectToolsContext.Provider value={value}>
value={{
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}}
>
{children} {children}
</DashboardObjectToolsContext.Provider> </DashboardObjectToolsContext.Provider>
) )
@ -59,6 +64,7 @@ export const useDashboardObjectToolsContext = () => {
export const useDashboardObjectTools = (tools) => { export const useDashboardObjectTools = (tools) => {
const { setCurrentObjectTools, clearCurrentObjectTools } = const { setCurrentObjectTools, clearCurrentObjectTools } =
useDashboardObjectToolsContext() useDashboardObjectToolsContext()
const store = useContext(NavigationTabActiveContext)
const ownerIdRef = useRef(null) const ownerIdRef = useRef(null)
if (ownerIdRef.current == null) { if (ownerIdRef.current == null) {
@ -66,8 +72,14 @@ export const useDashboardObjectTools = (tools) => {
} }
useEffect(() => { useEffect(() => {
setCurrentObjectTools(tools, ownerIdRef.current) const sync = () => {
}, [tools, setCurrentObjectTools]) if (!store.getSnapshot()) return
setCurrentObjectTools(tools, ownerIdRef.current)
}
sync()
return store.subscribe(sync)
}, [setCurrentObjectTools, store, tools])
useEffect(() => { useEffect(() => {
const ownerId = ownerIdRef.current const ownerId = ownerIdRef.current

View File

@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
const [electronAvailable] = useState(isElectron()) const [electronAvailable] = useState(isElectron())
const navigate = useNavigate() const navigate = useNavigate()
const lastNavigationAtRef = useRef(0) const lastNavigationAtRef = useRef(0)
const tabHistoryHandlerRef = useRef(null)
const registerTabHistoryHandler = useCallback((handler) => {
tabHistoryHandlerRef.current =
typeof handler === 'function' ? handler : null
}, [])
const navigateHistory = useCallback( const navigateHistory = useCallback(
(direction) => { (direction) => {
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
if (now - lastNavigationAtRef.current < 300) return if (now - lastNavigationAtRef.current < 300) return
lastNavigationAtRef.current = now lastNavigationAtRef.current = now
if (tabHistoryHandlerRef.current) {
tabHistoryHandlerRef.current(direction)
return
}
if (direction === 'back') { if (direction === 'back') {
navigate(-1) navigate(-1)
} else if (direction === 'forward') { } else if (direction === 'forward') {
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
void desktopBridge.windowControl(action) void desktopBridge.windowControl(action)
} }
const getWindowSession = useCallback(async () => {
if (!electronAvailable) return null
return await desktopBridge.getWindowSession()
}, [electronAvailable])
const syncWindowTabs = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.syncWindowTabs(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const createAppWindow = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.createAppWindow(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const onNewTabRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newTab', handler)
},
[electronAvailable]
)
const onNewWindowRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newWindow', handler)
},
[electronAvailable]
)
const onTabMovedAway = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('tabMovedAway', handler)
},
[electronAvailable]
)
const beginTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.beginTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const completeTabDrop = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.completeTabDrop(payload)) || { ok: false }
},
[electronAvailable]
)
const cancelTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.cancelTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const getAuthSession = async () => { const getAuthSession = async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
return await desktopBridge.getAuthSession() return await desktopBridge.getAuthSession()
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
resizeSpotlightWindow, resizeSpotlightWindow,
setSidebarViewMenu, setSidebarViewMenu,
getElectronVersion, getElectronVersion,
getAppEngine getAppEngine,
registerTabHistoryHandler,
getWindowSession,
syncWindowTabs,
createAppWindow,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
}} }}
> >
{children} {children}

View File

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { message } from 'antd' import { message } from 'antd'
import { isElectrobunDesktop } from '../../../electrobun-bridge' import { isElectrobunDesktop } from '../../../electrobun-bridge'
@ -12,36 +12,52 @@ const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
export const MessageProvider = ({ children }) => { export const MessageProvider = ({ children }) => {
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP }) const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
const showMessage = (type, content, options = {}) => { const showMessage = useCallback(
return msgApi.open({ (type, content, options = {}) => {
type, return msgApi.open({
content, type,
...options content,
}) ...options
} })
},
[msgApi]
)
const showSuccess = (content, options = {}) => const showSuccess = useCallback(
showMessage('success', content, options) (content, options = {}) => showMessage('success', content, options),
const showInfo = (content, options = {}) => [showMessage]
showMessage('info', content, options) )
const showWarning = (content, options = {}) => const showInfo = useCallback(
showMessage('warning', content, options) (content, options = {}) => showMessage('info', content, options),
const showError = (content, options = {}) => [showMessage]
showMessage('error', content, options) )
const showLoading = (content, options = {}) => const showWarning = useCallback(
showMessage('loading', content, options) (content, options = {}) => showMessage('warning', content, options),
[showMessage]
)
const showError = useCallback(
(content, options = {}) => showMessage('error', content, options),
[showMessage]
)
const showLoading = useCallback(
(content, options = {}) => showMessage('loading', content, options),
[showMessage]
)
const value = useMemo(
() => ({
msgApi,
showSuccess,
showInfo,
showWarning,
showError,
showLoading
}),
[msgApi, showError, showInfo, showLoading, showSuccess, showWarning]
)
return ( return (
<MessageContext.Provider <MessageContext.Provider value={value}>
value={{
msgApi,
showSuccess,
showInfo,
showWarning,
showError,
showLoading
}}
>
{contextHolder} {contextHolder}
{children} {children}
</MessageContext.Provider> </MessageContext.Provider>

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import {
useContext, useContext,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef useRef
} from 'react' } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@ -121,7 +122,14 @@ const NotificationProvider = ({ children }) => {
const unreadCount = notifications.filter((n) => !n.read).length const unreadCount = notifications.filter((n) => !n.read).length
// Initial load / when we become authenticated and connected const closeNotificationCenter = useCallback(() => {
setNotificationCenterVisible(false)
}, [])
useEffect(() => {
closeNotificationCenter()
}, [location.pathname, closeNotificationCenter])
useEffect(() => { useEffect(() => {
if (!authenticated || !connected) return if (!authenticated || !connected) return
fetchNotificationsRef.current() fetchNotificationsRef.current()
@ -134,10 +142,6 @@ const NotificationProvider = ({ children }) => {
fetchNotificationsRef.current() fetchNotificationsRef.current()
}, [notificationCenterVisible]) }, [notificationCenterVisible])
useEffect(() => {
setNotificationCenterVisible(false)
}, [location.pathname])
useEffect(() => { useEffect(() => {
if (!authenticated || !registerNotificationListener) return if (!authenticated || !registerNotificationListener) return
const handleNotification = (notif) => { const handleNotification = (notif) => {
@ -169,28 +173,42 @@ const NotificationProvider = ({ children }) => {
return unregister return unregister
}, [authenticated, registerNotificationListener, api, deleteNotification]) }, [authenticated, registerNotificationListener, api, deleteNotification])
const value = useMemo(
() => ({
notificationCenterVisible,
toggleNotificationCenter,
notifications,
fetchNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
unreadCount,
notificationsLoading
}),
[
deleteAllNotifications,
deleteNotification,
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
notificationCenterVisible,
notifications,
notificationsLoading,
toggleNotificationCenter,
unreadCount
]
)
return ( return (
<NotificationContext.Provider <NotificationContext.Provider value={value}>
value={{
notificationCenterVisible,
toggleNotificationCenter,
notifications,
fetchNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
unreadCount,
notificationsLoading
}}
>
{contextHolder} {contextHolder}
{children} {children}
<Drawer <Drawer
title='Notifications' title='Notifications'
placement='right' placement='right'
width={isMobile ? '100%' : 460} width={isMobile ? '100%' : 460}
onClose={() => setNotificationCenterVisible(false)} onClose={closeNotificationCenter}
open={notificationCenterVisible} open={notificationCenterVisible}
> >
<NotificationCenter <NotificationCenter

View File

@ -14,6 +14,11 @@ import { AuthContext } from './AuthContext'
import { useTableState } from './TableStateContext' import { useTableState } from './TableStateContext'
import useViewMode from '../hooks/useViewMode' import useViewMode from '../hooks/useViewMode'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils' import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
import { getModelByName } from '../../../database/ObjectModels'
import {
useNavigationTabPage,
useNavigationTabs
} from './NavigationTabsContext'
const ObjectListViewContext = createContext() const ObjectListViewContext = createContext()
@ -126,7 +131,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
subscribeToObjectUpdates subscribeToObjectUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext) const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView, setObjectListView } = useTableState() const { getViewFromUrl, getPersistedView, persistView, setObjectListView } =
useTableState()
const { isElectron } = useNavigationTabs()
const listModel = getModelByName(objectType)
const [views, setViews] = useState(() => readCachedViews(objectType) || []) const [views, setViews] = useState(() => readCachedViews(objectType) || [])
const [draftViews, setDraftViews] = useState(() => { const [draftViews, setDraftViews] = useState(() => {
@ -194,6 +202,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
return view return view
}, [activeTabKey, displayedViews]) }, [activeTabKey, displayedViews])
const listLabel = listModel?.labelPlural || 'List'
const viewName = activeView?.name
useNavigationTabPage({
title: viewName ? `${listLabel} - ${viewName}` : listLabel,
modelName: objectType
})
viewsStateRef.current = { views, draftViews, isEditing } viewsStateRef.current = { views, draftViews, isEditing }
activeViewRef.current = activeView activeViewRef.current = activeView
@ -288,11 +303,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
const name = const name =
key && view && typeof view === 'object' ? view.name || null : null key && view && typeof view === 'object' ? view.name || null : null
persistView(key) persistView(key, { scope: objectType, name })
writeLastViewId(objectType, key) if (!isElectron) {
writeLastViewId(objectType, key)
}
setObjectListView(objectType, key ? { id: key, name } : null) setObjectListView(objectType, key ? { id: key, name } : null)
}, },
[objectType, persistView, setObjectListView] [isElectron, objectType, persistView, setObjectListView]
) )
useEffect(() => { useEffect(() => {
@ -344,9 +361,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// Allow early restore from cache; only block when we have nothing to match against yet // Allow early restore from cache; only block when we have nothing to match against yet
if (initialLoading && views.length === 0) return if (initialLoading && views.length === 0) return
const urlViewId = getViewFromUrl() const persistedViewId = getPersistedView(objectType)?.id || null
const rememberedViewId = readLastViewId(objectType) const urlViewId = isElectron ? null : getViewFromUrl()
const candidateViewId = urlViewId || rememberedViewId const rememberedViewId = isElectron ? null : readLastViewId(objectType)
const candidateViewId = persistedViewId || urlViewId || rememberedViewId
if (!candidateViewId) { if (!candidateViewId) {
urlSyncedRef.current = true urlSyncedRef.current = true
@ -361,14 +379,18 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
if (matched) { if (matched) {
const matchedId = String(matched._id) const matchedId = String(matched._id)
setActiveTabKey(matchedId) setActiveTabKey(matchedId)
writeLastViewId(objectType, matchedId) if (!isElectron) {
writeLastViewId(objectType, matchedId)
}
setObjectListView(objectType, { setObjectListView(objectType, {
id: matchedId, id: matchedId,
name: matched.name || null name: matched.name || null
}) })
if (!matched._isDraft) { if (!matched._isDraft) {
// Keep viewId in the URL and strip any leftover filter/sort params persistView(matchedId, {
persistView(matchedId) scope: objectType,
name: matched.name || null
})
} }
urlSyncedRef.current = true urlSyncedRef.current = true
setViewSyncReady(true) setViewSyncReady(true)
@ -391,6 +413,8 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
draftViews, draftViews,
isEditing, isEditing,
objectType, objectType,
isElectron,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
rememberActiveView, rememberActiveView,
@ -894,36 +918,33 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// route filter/sort changes into the draft instead of URL/session persistence. // route filter/sort changes into the draft instead of URL/session persistence.
const activeObjectView = isEditing ? activeView : null const activeObjectView = isEditing ? activeView : null
const handleViewModeChange = useCallback( const handleViewModeChange = useCallback(async (viewMode) => {
async (viewMode) => { const view = activeViewRef.current
const view = activeViewRef.current if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
if (isEditingRef.current) { if (isEditingRef.current) {
const updateInList = (list) => const updateInList = (list) =>
list.map((item) => list.map((item) =>
String(item._id) === String(view._id) ? { ...item, viewMode } : item String(item._id) === String(view._id) ? { ...item, viewMode } : item
) )
activeViewRef.current = { ...view, viewMode } activeViewRef.current = { ...view, viewMode }
setDraftViews((prev) => updateInList(prev)) setDraftViews((prev) => updateInList(prev))
viewsStateRef.current = { viewsStateRef.current = {
...viewsStateRef.current, ...viewsStateRef.current,
draftViews: updateInList(viewsStateRef.current.draftViews) draftViews: updateInList(viewsStateRef.current.draftViews)
}
return
} }
return
}
// Not editing: store as a temporary user override instead of saving // Not editing: store as a temporary user override instead of saving
setUserViewModeOverride(viewMode) setUserViewModeOverride(viewMode)
userOverridesRef.current = { userOverridesRef.current = {
...(userOverridesRef.current || {}), ...(userOverridesRef.current || {}),
viewMode viewMode
} }
}, }, [])
[]
)
const isCustomView = activeTabKey !== ALL_TAB_KEY const isCustomView = activeTabKey !== ALL_TAB_KEY

View File

@ -9,6 +9,10 @@ import {
} from 'react' } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {
NavigationTabIdContext,
useNavigationTabs
} from './NavigationTabsContext'
const TableStateContext = createContext(null) const TableStateContext = createContext(null)
@ -166,6 +170,9 @@ const normalizeSorter = (sorter) => {
} }
export const TableStateProvider = ({ children }) => { export const TableStateProvider = ({ children }) => {
const tabId = useContext(NavigationTabIdContext)
const { isElectron, getTabListState, setTabListState, activeTab } =
useNavigationTabs()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [pageFilters, setPageFilters] = useState({}) const [pageFilters, setPageFilters] = useState({})
const [pageSorters, setPageSorters] = useState({}) const [pageSorters, setPageSorters] = useState({})
@ -214,8 +221,17 @@ export const TableStateProvider = ({ children }) => {
[setSearchParams] [setSearchParams]
) )
const resolveScopedTabId = useCallback(
() => tabId || (isElectron ? activeTab?.id : null),
[activeTab?.id, isElectron, tabId]
)
const getPersistedFilter = useCallback( const getPersistedFilter = useCallback(
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => { (scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).filter || {}
}
if (useFilterInUrl) { if (useFilterInUrl) {
const fromUrl = readFilterFromUrl(searchParams) const fromUrl = readFilterFromUrl(searchParams)
if (fromUrl) return fromUrl if (fromUrl) return fromUrl
@ -226,11 +242,15 @@ export const TableStateProvider = ({ children }) => {
} }
return {} return {}
}, },
[searchParams] [getTabListState, isElectron, resolveScopedTabId, searchParams]
) )
const getPersistedSorter = useCallback( const getPersistedSorter = useCallback(
(scope, { useSortInUrl = false, useSortInSession = false } = {}) => { (scope, { useSortInUrl = false, useSortInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).sort || {}
}
if (useSortInUrl) { if (useSortInUrl) {
const fromUrl = readSortFromUrl(searchParams) const fromUrl = readSortFromUrl(searchParams)
if (fromUrl) return fromUrl if (fromUrl) return fromUrl
@ -241,7 +261,7 @@ export const TableStateProvider = ({ children }) => {
} }
return {} return {}
}, },
[searchParams] [getTabListState, isElectron, resolveScopedTabId, searchParams]
) )
const getViewFromUrl = useCallback( const getViewFromUrl = useCallback(
@ -249,8 +269,36 @@ export const TableStateProvider = ({ children }) => {
[searchParams] [searchParams]
) )
const getPersistedView = useCallback(
(scope) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
const fromUrl = readViewFromUrl(searchParams)
if (fromUrl) return { id: String(fromUrl), name: null }
return readListViewFromSession(scope)
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const persistView = useCallback( const persistView = useCallback(
(viewId, { clearFilterSort = Boolean(viewId) } = {}) => { (
viewId,
{ clearFilterSort = Boolean(viewId), scope, name } = {}
) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId && scope) {
setTabListState(scopedTabId, scope, {
viewId: viewId ? String(viewId) : null,
viewName: viewId ? name || null : null
})
return
}
updateSearchParams((next) => { updateSearchParams((next) => {
// Drop legacy `view` param that previously stored `_reference` // Drop legacy `view` param that previously stored `_reference`
next.delete('view') next.delete('view')
@ -266,7 +314,7 @@ export const TableStateProvider = ({ children }) => {
} }
}) })
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistFilter = useCallback( const persistFilter = useCallback(
@ -276,6 +324,13 @@ export const TableStateProvider = ({ children }) => {
{ saveFilterInSession = false, saveFilterInUrl = false } = {} { saveFilterInSession = false, saveFilterInUrl = false } = {}
) => { ) => {
const active = getActiveFilterValues(filterState) const active = getActiveFilterValues(filterState)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveFilterInSession || saveFilterInUrl) {
setTabListState(scopedTabId, scope, { filter: active })
}
return
}
if (saveFilterInSession) { if (saveFilterInSession) {
writeFilterToSession(scope, active) writeFilterToSession(scope, active)
} }
@ -289,7 +344,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistSort = useCallback( const persistSort = useCallback(
@ -299,6 +354,13 @@ export const TableStateProvider = ({ children }) => {
{ saveSortInSession = false, saveSortInUrl = false } = {} { saveSortInSession = false, saveSortInUrl = false } = {}
) => { ) => {
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveSortInSession || saveSortInUrl) {
setTabListState(scopedTabId, scope, { sort: nextSorter })
}
return
}
if (saveSortInSession) { if (saveSortInSession) {
writeSortToSession(scope, nextSorter) writeSortToSession(scope, nextSorter)
} }
@ -312,7 +374,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistTableState = useCallback( const persistTableState = useCallback(
@ -329,6 +391,21 @@ export const TableStateProvider = ({ children }) => {
) => { ) => {
const active = getActiveFilterValues(filterState) const active = getActiveFilterValues(filterState)
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const patch = {}
if (saveFilterInSession || saveFilterInUrl) {
patch.filter = active
}
if (saveSortInSession || saveSortInUrl) {
patch.sort = nextSorter
}
if (Object.keys(patch).length > 0) {
setTabListState(scopedTabId, scope, patch)
}
return
}
if (saveFilterInSession) { if (saveFilterInSession) {
writeFilterToSession(scope, active) writeFilterToSession(scope, active)
@ -356,7 +433,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const setPageFilter = useCallback((path, filter) => { const setPageFilter = useCallback((path, filter) => {
@ -389,7 +466,12 @@ export const TableStateProvider = ({ children }) => {
const setObjectListFilter = useCallback((objectType, filter) => { const setObjectListFilter = useCallback((objectType, filter) => {
if (!objectType) return if (!objectType) return
const active = getActiveFilterValues(filter) const active = getActiveFilterValues(filter)
writeListFilterToSession(objectType, active) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listFilter: active })
} else {
writeListFilterToSession(objectType, active)
}
setObjectListFilters((prev) => { setObjectListFilters((prev) => {
const hasActive = Object.keys(active).length > 0 const hasActive = Object.keys(active).length > 0
if (!hasActive) { if (!hasActive) {
@ -400,12 +482,17 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: active } return { ...prev, [objectType]: active }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListSorter = useCallback((objectType, sorter) => { const setObjectListSorter = useCallback((objectType, sorter) => {
if (!objectType) return if (!objectType) return
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
writeListSortToSession(objectType, nextSorter) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listSort: nextSorter })
} else {
writeListSortToSession(objectType, nextSorter)
}
setObjectListSorters((prev) => { setObjectListSorters((prev) => {
const hasSort = nextSorter?.field && nextSorter?.order const hasSort = nextSorter?.field && nextSorter?.order
if (!hasSort) { if (!hasSort) {
@ -416,7 +503,7 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: nextSorter } return { ...prev, [objectType]: nextSorter }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListView = useCallback((objectType, view) => { const setObjectListView = useCallback((objectType, view) => {
if (!objectType) return if (!objectType) return
@ -424,7 +511,15 @@ export const TableStateProvider = ({ children }) => {
view?.id != null view?.id != null
? { id: String(view.id), name: view.name || null } ? { id: String(view.id), name: view.name || null }
: null : null
writeListViewToSession(objectType, nextView) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, {
viewId: nextView?.id || null,
viewName: nextView?.name || null
})
} else {
writeListViewToSession(objectType, nextView)
}
setObjectListViews((prev) => { setObjectListViews((prev) => {
if (!nextView) { if (!nextView) {
if (!(objectType in prev)) return prev if (!(objectType in prev)) return prev
@ -442,7 +537,7 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: nextView } return { ...prev, [objectType]: nextView }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const getObjectListFilter = useCallback( const getObjectListFilter = useCallback(
(objectType) => { (objectType) => {
@ -450,9 +545,15 @@ export const TableStateProvider = ({ children }) => {
if (objectListFilters[objectType]) { if (objectListFilters[objectType]) {
return objectListFilters[objectType] return objectListFilters[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getActiveFilterValues(
getTabListState(scopedTabId, objectType).listFilter || {}
)
}
return getActiveFilterValues(readListFilterFromSession(objectType) || {}) return getActiveFilterValues(readListFilterFromSession(objectType) || {})
}, },
[objectListFilters] [getTabListState, isElectron, objectListFilters, resolveScopedTabId]
) )
const getObjectListSorter = useCallback( const getObjectListSorter = useCallback(
@ -461,9 +562,13 @@ export const TableStateProvider = ({ children }) => {
if (objectListSorters[objectType]) { if (objectListSorters[objectType]) {
return objectListSorters[objectType] return objectListSorters[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, objectType).listSort || {}
}
return readListSortFromSession(objectType) || {} return readListSortFromSession(objectType) || {}
}, },
[objectListSorters] [getTabListState, isElectron, objectListSorters, resolveScopedTabId]
) )
const getObjectListView = useCallback( const getObjectListView = useCallback(
@ -472,9 +577,16 @@ export const TableStateProvider = ({ children }) => {
if (objectListViews[objectType]) { if (objectListViews[objectType]) {
return objectListViews[objectType] return objectListViews[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, objectType)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
return readListViewFromSession(objectType) return readListViewFromSession(objectType)
}, },
[objectListViews] [getTabListState, isElectron, objectListViews, resolveScopedTabId]
) )
const hasPageFilter = useCallback( const hasPageFilter = useCallback(
@ -484,6 +596,17 @@ export const TableStateProvider = ({ children }) => {
const hasStoredFilter = useCallback((scope) => { const hasStoredFilter = useCallback((scope) => {
if (!scope) return false if (!scope) return false
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
if (entry.viewId) {
return Object.keys(entry.listFilter || {}).length > 0
}
return (
Object.keys(entry.filter || {}).length > 0 ||
Object.keys(entry.listFilter || {}).length > 0
)
}
// When a view is active, only report the effective list filter (which // When a view is active, only report the effective list filter (which
// already accounts for the view). Don't fall back to the All-tab session // already accounts for the view). Don't fall back to the All-tab session
// filter — that belongs to a different context. // filter — that belongs to a different context.
@ -493,12 +616,19 @@ export const TableStateProvider = ({ children }) => {
if (hasStoredSessionFilter(scope)) return true if (hasStoredSessionFilter(scope)) return true
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0 return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
}, [objectListFilters, objectListViews]) }, [
getTabListState,
isElectron,
objectListFilters,
objectListViews,
resolveScopedTabId
])
const value = useMemo( const value = useMemo(
() => ({ () => ({
getPersistedFilter, getPersistedFilter,
getPersistedSorter, getPersistedSorter,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
persistFilter, persistFilter,
@ -523,6 +653,7 @@ export const TableStateProvider = ({ children }) => {
[ [
getPersistedFilter, getPersistedFilter,
getPersistedSorter, getPersistedSorter,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
persistFilter, persistFilter,

View File

@ -2,6 +2,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useMemo,
useState, useState,
useEffect useEffect
} from 'react' } from 'react'
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
) )
}, [showNavigationLabels]) }, [showNavigationLabels])
const toggleTheme = () => { const toggleTheme = useCallback(() => {
if (isSystem) { setIsSystem((current) => {
setIsSystem(false) if (current) return false
} return current
setIsDarkMode(!isDarkMode) })
} setIsDarkMode((current) => !current)
}, [])
const toggleSystem = () => { const toggleSystem = useCallback(() => {
setIsSystem(!isSystem) setIsSystem((current) => {
if (!isSystem) { const next = !current
// When enabling system theme, update to match system preference if (next) {
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches) setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
} }
} return next
})
}, [])
const toggleCompact = () => { const toggleCompact = useCallback(() => {
setIsCompact(!isCompact) setIsCompact((current) => !current)
} }, [])
const setThemeMode = useCallback((value) => { const setThemeMode = useCallback((value) => {
if (value === 'system') { if (value === 'system') {
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
setShowNavigationLabelsState(Boolean(value)) setShowNavigationLabelsState(Boolean(value))
}, []) }, [])
const getThemeAlgorithm = () => { const getColors = useCallback(() => COLORS, [])
var baseAlgorithm
if (isDarkMode == true) {
baseAlgorithm = theme.darkAlgorithm
} else {
baseAlgorithm = theme.defaultAlgorithm
}
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
}
const getColors = () => { const themeConfig = useMemo(() => {
return COLORS const baseAlgorithm = isDarkMode
} ? theme.darkAlgorithm
: theme.defaultAlgorithm
return {
algorithm: isCompact
? [theme.compactAlgorithm, baseAlgorithm]
: [baseAlgorithm],
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
}, [isDarkMode, isCompact, primaryColorOverride])
const value = useMemo(
() => ({
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}),
[
getColors,
isCompact,
isDarkMode,
isSystem,
setDensityMode,
setShowNavigationLabels,
setThemeMode,
showNavigationLabels,
themeConfig,
toggleCompact,
toggleSystem,
toggleTheme
]
)
// Set CSS custom properties for theme colors // Set CSS custom properties for theme colors
useEffect(() => { useEffect(() => {
@ -175,6 +223,10 @@ export const ThemeProvider = ({ children }) => {
'--color-background', '--color-background',
isDarkMode ? '#000000' : '#ffffff' isDarkMode ? '#000000' : '#ffffff'
) )
root.style.setProperty(
'--tab-preview-render-background',
isDarkMode ? '#000000' : '#E9E9E9'
)
root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000') root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000')
root.style.setProperty( root.style.setProperty(
'--color-descriptions-border', '--color-descriptions-border',
@ -216,43 +268,14 @@ export const ThemeProvider = ({ children }) => {
'--color-mobile-footer-item-border', '--color-mobile-footer-item-border',
isDarkMode ? '#303030' : '#f0f0f0' isDarkMode ? '#303030' : '#f0f0f0'
) )
root.style.setProperty(
'--color-header-border',
isDarkMode ? 'rgba(253,253,253,0.12)' : 'rgba(10,10,10,0.12)'
)
}, [isDarkMode, primaryColorOverride]) }, [isDarkMode, primaryColorOverride])
const themeConfig = {
algorithm: getThemeAlgorithm(),
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
return ( return (
<ThemeContext.Provider <ThemeContext.Provider value={value}>
value={{
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
) )

View File

@ -0,0 +1,194 @@
import { snapdom } from '@zumer/snapdom'
const FONT_TIMEOUT_MS = 180
const CAPTURE_CLASS = 'dashboard-tab-pane-capturing'
const CAPTURE_FRAME_CLASS = 'dashboard-tab-preview-capture-frame'
const CAPTURE_PADDING_PX = 24
const PREVIEW_EXCLUDE = [
'.ant-popover',
'.dashboard-tab-preview-popover',
'.dashboard-tab-preview'
]
const waitForPaint = () =>
new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve)
})
})
const waitForTimeout = (ms) =>
new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
const waitWithTimeout = (promise, ms) =>
Promise.race([promise, waitForTimeout(ms)])
const getCssVariableColor = (name) =>
window.getComputedStyle(document.documentElement).getPropertyValue(name).trim()
const cssColorToRgb = (color, fallback) => {
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
return fallback
}
if (
color.startsWith('#') ||
color.startsWith('rgb(') ||
color.startsWith('rgba(')
) {
return color
}
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 1
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return fallback
ctx.fillStyle = fallback
ctx.fillRect(0, 0, 1, 1)
ctx.fillStyle = color
ctx.fillRect(0, 0, 1, 1)
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data
if (!a) return fallback
return `rgb(${r}, ${g}, ${b})`
}
const getPreviewBackgroundColor = (pane) => {
const themeColor = getCssVariableColor('--tab-preview-render-background')
if (themeColor) {
return cssColorToRgb(themeColor, themeColor)
}
return cssColorToRgb(
(pane && window.getComputedStyle(pane).backgroundColor) ||
window.getComputedStyle(document.body).backgroundColor,
getCssVariableColor('--color-background') || '#ffffff'
)
}
const waitForFonts = async () => {
if (!document.fonts?.ready) return
await waitWithTimeout(document.fonts.ready, FONT_TIMEOUT_MS)
}
export const waitForTabPreviewLayout = async (pane) => {
if (!pane) return false
void pane.offsetHeight
await waitForFonts()
await waitForPaint()
void pane.offsetHeight
await waitForPaint()
return pane.clientWidth >= 8 && pane.clientHeight >= 8
}
const resetCloneLayout = (element) => {
const styles = element.style
styles.position = 'relative'
styles.inset = 'auto'
styles.left = '0'
styles.top = '0'
styles.right = 'auto'
styles.bottom = 'auto'
styles.zIndex = '2'
styles.display = 'flex'
styles.flexDirection = 'column'
styles.opacity = '1'
styles.visibility = 'visible'
styles.translate = 'none'
styles.transform = 'none'
styles.overflow = 'hidden'
}
const preparePaneForCapture = (pane) => {
const previousInert = pane.inert
const parent = pane.parentNode
const nextSibling = pane.nextSibling
const frame = document.createElement('div')
frame.className = CAPTURE_FRAME_CLASS
frame.style.padding = `${CAPTURE_PADDING_PX}px`
if (parent) {
parent.insertBefore(frame, pane)
frame.appendChild(pane)
}
pane.classList.add(CAPTURE_CLASS)
pane.inert = false
void frame.offsetHeight
void pane.offsetHeight
return {
captureTarget: pane.parentNode === frame ? frame : pane,
restore: () => {
if (pane.parentNode === frame) {
if (nextSibling && nextSibling.parentNode === parent) {
parent.insertBefore(pane, nextSibling)
} else if (parent) {
parent.appendChild(pane)
}
}
frame.remove()
pane.classList.remove(CAPTURE_CLASS)
pane.inert = previousInert
}
}
}
const tabPreviewPlugin = {
name: 'tab-preview-layout',
afterClone(ctx) {
const element = ctx.clone
if (!(element instanceof HTMLElement)) return
const pane = element.classList.contains('dashboard-tab-pane')
? element
: element.querySelector('.dashboard-tab-pane')
resetCloneLayout(element)
if (!(pane instanceof HTMLElement)) return
pane.classList.remove('dashboard-tab-pane-inactive', CAPTURE_CLASS)
pane.classList.add('dashboard-tab-pane-active')
resetCloneLayout(pane)
pane.style.flex = '1 1 auto'
pane.style.width = '100%'
pane.style.height = 'auto'
pane.style.minHeight = '0'
}
}
export const captureTabPaneImage = async (pane) => {
if (!pane) return null
const { captureTarget, restore } = preparePaneForCapture(pane)
try {
const ready = await waitForTabPreviewLayout(pane)
if (!ready) return null
const backgroundColor = getPreviewBackgroundColor(pane)
const jpg = await snapdom.toJpg(captureTarget, {
backgroundColor,
scale: 1,
embedFonts: 'auto',
exclude: PREVIEW_EXCLUDE,
excludeMode: 'remove',
outerTransforms: false,
placeholders: false,
invalidate: true,
plugins: [tabPreviewPlugin]
})
return jpg?.src || null
} catch (error) {
console.error('Failed to capture tab preview', error)
return null
} finally {
restore()
}
}

View File

@ -0,0 +1,71 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const formatPageLabel = (pageName) =>
pageName ? `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)}` : 'Page'
const getObjectDisplayName = (objectData, model) => {
if (objectData?.name) return objectData.name
if (objectData?._reference && model?.prefix) {
return `${model.prefix}:${objectData._reference}`
}
return null
}
export const formatObjectPageTabTitle = ({ pageName, objectName } = {}) => {
const pageLabel = formatPageLabel(pageName)
if (objectName) return `${objectName} - ${pageLabel}`
return pageLabel
}
export const useObjectNavigationTabPage = ({
modelName,
pageName,
objectId
} = {}) => {
const model = getModelByName(modelName)
const { fetchSpotlightData, subscribeToObjectUpdates, connected } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const [objectName, setObjectName] = useState(null)
const fetchSpotlightDataRef = useRef(fetchSpotlightData)
fetchSpotlightDataRef.current = fetchSpotlightData
useEffect(() => {
if (!objectId || !model?.prefix || token == null) {
setObjectName(null)
return undefined
}
let cancelled = false
const query = `${model.prefix}:${objectId}`
void fetchSpotlightDataRef.current(query).then((result) => {
if (cancelled) return
if (result && typeof result === 'object' && !Array.isArray(result)) {
setObjectName(getObjectDisplayName(result, model))
}
})
return () => {
cancelled = true
}
}, [model, objectId, token])
useEffect(() => {
if (!objectId || !modelName || !connected) return undefined
return subscribeToObjectUpdates(objectId, modelName, (value) => {
setObjectName((current) => getObjectDisplayName(value, model) || current)
})
}, [connected, model, modelName, objectId, subscribeToObjectUpdates])
useNavigationTabPage({
title: formatObjectPageTabTitle({ pageName, objectName }),
modelName
})
}
export default useObjectNavigationTabPage

Some files were not shown because too many files have changed in this diff Show More