Merge branch 'electrobun-tabs'
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-09-26 23:17:07 +01:00
commit 3cd93bff21
110 changed files with 6865 additions and 4220 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;
@ -576,17 +702,9 @@ body {
min-width: 0 !important; min-width: 0 !important;
max-width: 100% !important; max-width: 100% !important;
flex: 0 0 100% !important; flex: 0 0 100% !important;
transition: transition: none;
all 0.3s cubic-bezier(0.2, 0, 0, 1) 0s,
background 0s;
} }
.dashboard-sider.ant-layout-sider-collapsed {
width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
min-width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
max-width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
flex: 0 0 var(--dashboard-sidebar-collapsed-width, 80px) !important;
}
/* --- End of src/components/Dashboard/common/DashboardSidebar.css --- */ /* --- End of src/components/Dashboard/common/DashboardSidebar.css --- */
.objectTableDescritions > .ant-descriptions-view > table { .objectTableDescritions > .ant-descriptions-view > table {
@ -1417,6 +1535,11 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
cubic-bezier(0.2, 0, 0, 1); cubic-bezier(0.2, 0, 0, 1);
} }
.adaptive-splitter.is-ready:not(.is-dragging)
> .adaptive-splitter-panel.ant-splitter-panel.dashboard-splitter-content-panel {
transition: none;
}
.farmcontrol-splitter .farmcontrol-splitter
.ant-splitter-panel:has(~ .ant-splitter-panel) .ant-splitter-panel:has(~ .ant-splitter-panel)
.info-collapse.ant-collapse.ant-collapse-icon-position-end .info-collapse.ant-collapse.ant-collapse-icon-position-end
@ -1891,7 +2014,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 +3469,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

@ -12,14 +12,74 @@
<link rel="apple-touch-icon" href="./logo192.png" /> <link rel="apple-touch-icon" href="./logo192.png" />
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./fonts/fonts.css" preload /> <link rel="stylesheet" href="./fonts/fonts.css" preload />
<link rel="stylesheet" href="./preload.css" preload /> <style>
.fc-preload {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
/* HTML: <div class="loader"></div> */
.fc-loader {
width: 26px;
aspect-ratio: 1;
}
.fc-loader svg {
width: 100%;
height: 100%;
}
.fc-loader svg path {
stroke-width: 5px;
stroke: #008eff;
}
</style>
<script type="module" src="/src/index.jsx"></script> <script type="module" src="/src/index.jsx"></script>
<title>Farm Control</title> <title>Farm Control</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"> <div id="root">
<div class="fc-preload"><div class="fc-loader"></div></div> <div class="fc-preload">
<div class="fc-loader">
<svg
width="100%"
height="100%"
viewBox="0 0 64 64"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
class="th-icon"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
"
>
<g>
<path
d="M32,5.131C46.829,5.131 58.869,17.171 58.869,32"
style="fill: none"
/>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 32 32"
to="360 32 32"
dur="1s"
repeatCount="indefinite"
/>
</g>
</svg>
</div>
</div>
</div> </div>
</body> </body>
</html> </html>

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

@ -1,26 +0,0 @@
.fc-preload {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
/* HTML: <div class="loader"></div> */
.fc-loader {
width: 35px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side, #008eff 94%, #0000) top/6px 6px no-repeat,
conic-gradient(#0000 30%, #008eff);
mask: radial-gradient(farthest-side, #0000 calc(100% - 6px), #000 0);
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 6px), #000 0);
animation: l13 1s infinite linear;
}
@keyframes l13 {
100% {
transform: rotate(1turn);
}
}

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

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