Compare commits
38 Commits
main
...
electrobun
| Author | SHA1 | Date | |
|---|---|---|---|
| d5096dbba3 | |||
| e7f3e2a6d4 | |||
| 4f6f7cb881 | |||
| d348d6f2b3 | |||
| 44c8729787 | |||
| 4c1766044f | |||
| b54669fd26 | |||
| 396327f6ef | |||
| e446098a76 | |||
| 24a3a03c65 | |||
| 2b0565e69e | |||
| 999c960339 | |||
| ae2ea0dc13 | |||
| 877db10c09 | |||
| 0aee5b410f | |||
| 969f5fece2 | |||
| fcad667516 | |||
| f56646f906 | |||
| 89fc70a8ea | |||
| d9f3bb185f | |||
| e44f0b2ead | |||
| 48d2b0d15a | |||
| 0b4b8128de | |||
| abe183c82a | |||
| 7ab05c8c2d | |||
| 9ca872e589 | |||
| 84db1f19b7 | |||
| 5498aa3368 | |||
| 251bb716cf | |||
| d52eea3a41 | |||
| f3af883bd2 | |||
| 5cc74bca18 | |||
| 65417f75b8 | |||
| c39051a891 | |||
| d6c72c21e8 | |||
| 7fef25d911 | |||
| 2f9f42c9e2 | |||
| d16aa373be |
@ -112,6 +112,98 @@
|
||||
--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 * {
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
@ -145,18 +237,50 @@ html.macos-vibrancy .main-layout {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
background: rgba(10, 10, 10, 0.82) !important;
|
||||
}
|
||||
@ -193,11 +317,11 @@ html.macos-vibrancy
|
||||
}
|
||||
|
||||
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 {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
background: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
html.macos-vibrancy .electron-navigation {
|
||||
@ -367,6 +491,7 @@ code {
|
||||
:root,
|
||||
.light-mode,
|
||||
html:has(.light-mode) {
|
||||
--color-layout-background: #ffffff;
|
||||
--kbd-color: #363636;
|
||||
--kbd-bg: #efefef;
|
||||
--kbd-inset: #e8e8e8;
|
||||
@ -378,6 +503,7 @@ html:has(.light-mode) {
|
||||
|
||||
.dark-mode,
|
||||
html:has(.dark-mode) {
|
||||
--color-layout-background: #000000;
|
||||
--kbd-color: #ececec;
|
||||
--kbd-bg: #3a3a3a;
|
||||
--kbd-inset: #323232;
|
||||
@ -1891,7 +2017,7 @@ body.objectKanbanColumnResizing * {
|
||||
.electron-body .ant-modal-wrap,
|
||||
.electron-body .ant-modal-mask,
|
||||
.electron-body .ant-drawer {
|
||||
top: 41px;
|
||||
top: 42px;
|
||||
}
|
||||
|
||||
@keyframes h-progress-macos-wave {
|
||||
@ -3346,6 +3472,732 @@ body.objectKanbanColumnResizing * {
|
||||
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 {
|
||||
stroke: currentColor;
|
||||
stroke-width: 12px;
|
||||
|
||||
@ -47,6 +47,7 @@
|
||||
"@tsparticles/slim": "^3.9.1",
|
||||
"@uiw/react-codemirror": "^4.25.1",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"@zumer/snapdom": "^3.0.0",
|
||||
"antd": "^5.27.1",
|
||||
"antd-style": "^3.7.1",
|
||||
"axios": "^1.11.0",
|
||||
@ -127,9 +128,6 @@
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electrobun": "1.18.1",
|
||||
"electron": "^38.7.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-packager": "^17.1.2",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
|
||||
2157
pnpm-lock.yaml
generated
2157
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
26
src/App.jsx
26
src/App.jsx
@ -30,6 +30,7 @@ import AppError from './components/App/AppError'
|
||||
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
|
||||
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.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 { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
|
||||
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
|
||||
@ -64,26 +65,26 @@ const getRouter = () => {
|
||||
return BrowserRouter
|
||||
}
|
||||
|
||||
const renderEmpty = () => (
|
||||
<div style={{ margin: '32px' }}>
|
||||
<MissingPlaceholder
|
||||
message='No data.'
|
||||
hasBackground={false}
|
||||
hasBorder={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppContent = () => {
|
||||
const { themeConfig } = useThemeContext()
|
||||
const Router = getRouter()
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={themeConfig}
|
||||
renderEmpty={() => (
|
||||
<div style={{ margin: '32px' }}>
|
||||
<MissingPlaceholder
|
||||
message='No data.'
|
||||
hasBackground={false}
|
||||
hasBorder={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
|
||||
<App>
|
||||
<Router>
|
||||
<ElectronProvider>
|
||||
<NavigationTabsProvider>
|
||||
<AuthProvider>
|
||||
<PrintServerProvider>
|
||||
<ApiServerProvider>
|
||||
@ -183,6 +184,7 @@ const AppContent = () => {
|
||||
</ApiServerProvider>
|
||||
</PrintServerProvider>
|
||||
</AuthProvider>
|
||||
</NavigationTabsProvider>
|
||||
</ElectronProvider>
|
||||
</Router>
|
||||
</App>
|
||||
|
||||
@ -22,22 +22,22 @@ const {
|
||||
const {
|
||||
createMainWindow,
|
||||
handleDeepLinkFromArgv,
|
||||
persistSessionNow,
|
||||
setupDevAuthServer,
|
||||
setupNavigationGestures,
|
||||
setupWindowsDeepLinkHandling
|
||||
} = await import('../desktop/window.js')
|
||||
|
||||
setupWindowsDeepLinkHandling()
|
||||
|
||||
const rpc = createAppRpc()
|
||||
const mainWindow = await createMainWindow(rpc)
|
||||
await createMainWindow(rpc)
|
||||
|
||||
setupNavigationGestures(mainWindow)
|
||||
registerGlobalShortcuts(rpc)
|
||||
setupDevAuthServer()
|
||||
handleDeepLinkFromArgv(launchUrl)
|
||||
|
||||
process.on('exit', () => {
|
||||
persistSessionNow()
|
||||
unregisterGlobalShortcuts()
|
||||
closeSingleInstanceServer()
|
||||
})
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
// Dashboard.js
|
||||
import Layout from './Layout'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useNavigationTabs } from './context/NavigationTabsContext'
|
||||
import DashboardTabPanes from './common/DashboardTabPanes'
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
)
|
||||
const { isElectron } = useNavigationTabs()
|
||||
|
||||
return <Layout>{isElectron ? <DashboardTabPanes /> : <Outlet />}</Layout>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
@ -27,7 +27,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
||||
import UserNotifierToggle from '../../common/UserNotifierToggle.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 ShipmentIcon from '../../../Icons/ShipmentIcon.jsx'
|
||||
|
||||
@ -91,7 +94,7 @@ const InvoiceInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -100,6 +103,7 @@ const InvoiceInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -257,7 +261,9 @@ const InvoiceInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='payment'
|
||||
masterFilter={{ invoice: getModelByName('invoice').prefix + ':' + invoiceId }}
|
||||
masterFilter={{
|
||||
invoice: getModelByName('invoice').prefix + ':' + invoiceId
|
||||
}}
|
||||
visibleColumns={{ invoice: false }}
|
||||
/>
|
||||
)}
|
||||
@ -274,7 +280,7 @@ const InvoiceInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={invoiceId} type='invoice' />
|
||||
<NotesPanel _id={invoiceId} type='invoice' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -291,10 +297,7 @@ const InvoiceInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('invoice').prefix +
|
||||
':' +
|
||||
invoiceId
|
||||
parent: getModelByName('invoice').prefix + ':' + invoiceId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -93,7 +93,7 @@ const PaymentPolicyInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -84,7 +84,7 @@ const PaymentInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -93,6 +93,7 @@ const PaymentInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -209,7 +210,7 @@ const PaymentInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={paymentId} type='payment' />
|
||||
<NotesPanel _id={paymentId} type='payment' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
|
||||
@ -77,7 +77,7 @@ const TaxRecordInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,14 +85,14 @@ const TaxRecordInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='taxRecord'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={taxRecordId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -193,7 +193,7 @@ const TaxRecordInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={taxRecordId} type='taxRecord' />
|
||||
<NotesPanel _id={taxRecordId} type='taxRecord' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -212,10 +212,8 @@ const TaxRecordInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('taxRecord').prefix +
|
||||
':' +
|
||||
taxRecordId
|
||||
parent:
|
||||
getModelByName('taxRecord').prefix + ':' + taxRecordId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -87,14 +87,14 @@ const FilamentStockInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='filamentStock'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={filamentStockId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -197,7 +197,12 @@ const FilamentStockInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='stockEvent'
|
||||
masterFilter={{ parent: getModelByName('filamentStock').prefix + ':' + filamentStockId }}
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('filamentStock').prefix +
|
||||
':' +
|
||||
filamentStockId
|
||||
}}
|
||||
visibleColumns={{ parent: false }}
|
||||
/>
|
||||
)}
|
||||
@ -232,7 +237,7 @@ const FilamentStockInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={filamentStockId} type='filamentStock' />
|
||||
<NotesPanel _id={filamentStockId} type='filamentStock' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -251,10 +256,10 @@ const FilamentStockInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('filamentStock').prefix +
|
||||
':' +
|
||||
filamentStockId
|
||||
parent:
|
||||
getModelByName('filamentStock').prefix +
|
||||
':' +
|
||||
filamentStockId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -78,7 +78,7 @@ const OrderItemInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -88,6 +88,7 @@ const OrderItemInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -96,7 +97,7 @@ const OrderItemInfo = () => {
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='orderItem'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={orderItemId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -204,7 +205,7 @@ const OrderItemInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={orderItemId} type='orderItem' />
|
||||
<NotesPanel _id={orderItemId} type='orderItem' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -223,10 +224,8 @@ const OrderItemInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('orderItem').prefix +
|
||||
':' +
|
||||
orderItemId
|
||||
parent:
|
||||
getModelByName('orderItem').prefix + ':' + orderItemId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -93,6 +93,7 @@ const PartStockInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -101,7 +102,7 @@ const PartStockInfo = () => {
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='partStock'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={partStockId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -214,7 +215,10 @@ const PartStockInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='stockEvent'
|
||||
masterFilter={{ parent: getModelByName('partStock').prefix + ':' + partStockId }}
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('partStock').prefix + ':' + partStockId
|
||||
}}
|
||||
visibleColumns={{ parent: false }}
|
||||
/>
|
||||
)}
|
||||
@ -251,7 +255,7 @@ const PartStockInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={partStockId} type='partStock' />
|
||||
<NotesPanel _id={partStockId} type='partStock' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -271,10 +275,8 @@ const PartStockInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('partStock').prefix +
|
||||
':' +
|
||||
partStockId
|
||||
parent:
|
||||
getModelByName('partStock').prefix + ':' + partStockId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -109,6 +109,7 @@ const ProductStockInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
|
||||
@ -102,7 +102,7 @@ const PurchaseOrderInfo = () => {
|
||||
orderItemsTableRef?.current?.handleUpdate?.()
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -111,6 +111,7 @@ const PurchaseOrderInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -230,7 +231,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='orderItem'
|
||||
masterFilter={{
|
||||
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
|
||||
order:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId,
|
||||
orderType: 'purchaseOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -249,7 +253,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='shipment'
|
||||
masterFilter={{
|
||||
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
|
||||
order:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId,
|
||||
orderType: 'purchaseOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -271,7 +278,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='invoice'
|
||||
masterFilter={{
|
||||
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
|
||||
order:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId,
|
||||
orderType: 'purchaseOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -293,7 +303,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='stockEvent'
|
||||
masterFilter={{
|
||||
owner: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId
|
||||
owner:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -314,7 +327,7 @@ const PurchaseOrderInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
|
||||
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -331,10 +344,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId
|
||||
parent:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -121,6 +121,7 @@ const PurchaseOrderInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -129,7 +130,7 @@ const PurchaseOrderInfo = () => {
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='purchaseOrder'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={purchaseOrderId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -233,7 +234,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='orderItem'
|
||||
masterFilter={{
|
||||
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
|
||||
order:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId,
|
||||
orderType: 'purchaseOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -252,7 +256,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='shipment'
|
||||
masterFilter={{
|
||||
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
|
||||
order:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId,
|
||||
orderType: 'purchaseOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -275,7 +282,7 @@ const PurchaseOrderInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
|
||||
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -294,10 +301,10 @@ const PurchaseOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId
|
||||
parent:
|
||||
getModelByName('purchaseOrder').prefix +
|
||||
':' +
|
||||
purchaseOrderId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -96,6 +96,7 @@ const ShipmentInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
|
||||
@ -26,7 +26,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const log = loglevel.getLogger('StockAuditInfo')
|
||||
@ -92,6 +95,7 @@ const StockAuditInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -180,7 +184,9 @@ const StockAuditInfo = () => {
|
||||
title='Stock Audit Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('info', expanded)
|
||||
}
|
||||
collapseKey='info'
|
||||
>
|
||||
<ObjectInfo
|
||||
@ -262,9 +268,7 @@ const StockAuditInfo = () => {
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
|
||||
collapseKey='auditLogs'
|
||||
>
|
||||
{objectFormState.loading ? (
|
||||
@ -274,9 +278,7 @@ const StockAuditInfo = () => {
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('stockAudit').prefix +
|
||||
':' +
|
||||
stockAuditId
|
||||
getModelByName('stockAudit').prefix + ':' + stockAuditId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -98,6 +98,7 @@ const StockLocationInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -106,7 +107,7 @@ const StockLocationInfo = () => {
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='stockLocation'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={stockLocationId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -329,7 +330,7 @@ const StockLocationInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={stockLocationId} type='stockLocation' />
|
||||
<NotesPanel _id={stockLocationId} type='stockLocation' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -349,10 +350,10 @@ const StockLocationInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('stockLocation').prefix +
|
||||
':' +
|
||||
stockLocationId
|
||||
parent:
|
||||
getModelByName('stockLocation').prefix +
|
||||
':' +
|
||||
stockLocationId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -8,7 +8,10 @@ import InfoCollapse from '../../common/InfoCollapse.jsx'
|
||||
import ObjectInfo from '../../common/ObjectInfo.jsx'
|
||||
import ObjectProperty from '../../common/ObjectProperty.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 NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
@ -89,7 +92,7 @@ const StockTransferInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current.handleUpdate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -98,6 +101,7 @@ const StockTransferInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -263,7 +267,7 @@ const StockTransferInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={stockTransferId} type='stockTransfer' />
|
||||
<NotesPanel _id={stockTransferId} type='stockTransfer' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -281,10 +285,10 @@ const StockTransferInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('stockTransfer').prefix +
|
||||
':' +
|
||||
stockTransferId
|
||||
parent:
|
||||
getModelByName('stockTransfer').prefix +
|
||||
':' +
|
||||
stockTransferId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// DashboardLayout.js
|
||||
import PropTypes from 'prop-types'
|
||||
import { Layout, Flex } from 'antd'
|
||||
import { useContext } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import ProductionSidebar from './Production/ProductionSidebar'
|
||||
import InventorySidebar from './Inventory/InventorySidebar'
|
||||
@ -13,15 +14,16 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
|
||||
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
|
||||
import MobileFooterNavigation from './common/MobileFooterNavigation'
|
||||
import { useThemeContext } from './context/ThemeContext'
|
||||
import { MessageProvider } from './context/MessageContext'
|
||||
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { ElectronContext } from './context/ElectronContext'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
const DashboardLayout = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { isElectron } = useContext(ElectronContext)
|
||||
const isProduction = location.pathname.startsWith('/dashboard/production')
|
||||
const isInventory = location.pathname.startsWith('/dashboard/inventory')
|
||||
const isFinance = location.pathname.startsWith('/dashboard/finance')
|
||||
@ -49,31 +51,33 @@ const DashboardLayout = ({ children }) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<MessageProvider>
|
||||
<Layout
|
||||
style={{ height: 'var(--unit-100vh)' }}
|
||||
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
|
||||
>
|
||||
<DashboardNavigation />
|
||||
<DashboardSidebarSplitter sidebar={sidebar}>
|
||||
<Layout
|
||||
style={{ padding: '24px', height: '100%' }}
|
||||
className='main-content-layout'
|
||||
>
|
||||
<Content>
|
||||
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
|
||||
{currentObjectTools}
|
||||
</Flex>
|
||||
{children}
|
||||
<Layout
|
||||
style={{ height: 'var(--unit-100vh)' }}
|
||||
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
|
||||
>
|
||||
<DashboardNavigation />
|
||||
<DashboardSidebarSplitter sidebar={sidebar}>
|
||||
<Layout
|
||||
style={{ padding: '24px', height: '100%' }}
|
||||
className='main-content-layout'
|
||||
>
|
||||
<Content>
|
||||
<Flex
|
||||
vertical
|
||||
style={{ height: '100%' }}
|
||||
gap={isElectron ? '10px' : '20px'}
|
||||
>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
|
||||
{currentObjectTools}
|
||||
</Flex>
|
||||
</Content>
|
||||
</Layout>
|
||||
</DashboardSidebarSplitter>
|
||||
{isMobile ? <MobileFooterNavigation /> : null}
|
||||
</Layout>
|
||||
</MessageProvider>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
|
||||
</Flex>
|
||||
</Content>
|
||||
</Layout>
|
||||
</DashboardSidebarSplitter>
|
||||
{isMobile ? <MobileFooterNavigation /> : null}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
|
||||
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
||||
import ExternalLink from '../common/ExternalLink'
|
||||
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const About = () => {
|
||||
useNavigationTabPage({ title: 'About', iconKey: 'infoCircle' })
|
||||
const [collapseState, updateCollapseState] = useCollapseState('About', {
|
||||
updater: true
|
||||
})
|
||||
|
||||
@ -87,7 +87,7 @@ const AppPasswordInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -196,10 +196,8 @@ const AppPasswordInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('appPassword').prefix +
|
||||
':' +
|
||||
appPasswordId
|
||||
parent:
|
||||
getModelByName('appPassword').prefix + ':' + appPasswordId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const log = loglevel.getLogger('CourierServiceInfo')
|
||||
@ -82,7 +85,7 @@ const CourierServiceInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -90,14 +93,14 @@ const CourierServiceInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='courierService'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={courierServiceId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -222,7 +225,7 @@ const CourierServiceInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={courierServiceId} type='courierService' />
|
||||
<NotesPanel _id={courierServiceId} type='courierService' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -241,10 +244,10 @@ const CourierServiceInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('courierService').prefix +
|
||||
':' +
|
||||
courierServiceId
|
||||
parent:
|
||||
getModelByName('courierService').prefix +
|
||||
':' +
|
||||
courierServiceId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -75,7 +75,7 @@ const CourierInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -83,14 +83,14 @@ const CourierInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='courier'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={courierId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -193,7 +193,9 @@ const CourierInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='courierService'
|
||||
masterFilter={{ courier: getModelByName('courier').prefix + ':' + courierId }}
|
||||
masterFilter={{
|
||||
courier: getModelByName('courier').prefix + ':' + courierId
|
||||
}}
|
||||
visibleColumns={{
|
||||
courier: false
|
||||
}}
|
||||
@ -212,7 +214,7 @@ const CourierInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={courierId} type='courier' />
|
||||
<NotesPanel _id={courierId} type='courier' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -231,10 +233,7 @@ const CourierInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('courier').prefix +
|
||||
':' +
|
||||
courierId
|
||||
parent: getModelByName('courier').prefix + ':' + courierId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -87,14 +87,14 @@ const DocumentJobInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='documentJob'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={documentJobId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -196,7 +196,7 @@ const DocumentJobInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={documentJobId} type='documentJob' />
|
||||
<NotesPanel _id={documentJobId} type='documentJob' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -215,10 +215,8 @@ const DocumentJobInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('documentJob').prefix +
|
||||
':' +
|
||||
documentJobId
|
||||
parent:
|
||||
getModelByName('documentJob').prefix + ':' + documentJobId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -91,6 +91,7 @@ const DocumentPrinterInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
|
||||
@ -87,14 +87,14 @@ const DocumentSizeInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='documentSize'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={documentSizeId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -197,7 +197,7 @@ const DocumentSizeInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={documentSizeId} type='documentSize' />
|
||||
<NotesPanel _id={documentSizeId} type='documentSize' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -216,10 +216,10 @@ const DocumentSizeInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('documentSize').prefix +
|
||||
':' +
|
||||
documentSizeId
|
||||
parent:
|
||||
getModelByName('documentSize').prefix +
|
||||
':' +
|
||||
documentSizeId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -19,6 +19,7 @@ import { useDashboardObjectTools } from '../../context/DashboardObjectToolsConte
|
||||
import ScrollBox from '../../common/ScrollBox.jsx'
|
||||
|
||||
import TemplateEditor from '../../common/TemplateEditor.jsx'
|
||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const log = loglevel.getLogger('DocumentTemplateDesign')
|
||||
log.setLevel(config.logLevel)
|
||||
@ -30,6 +31,11 @@ const DocumentTemplateDesign = () => {
|
||||
const documentTemplateId = new URLSearchParams(location.search).get(
|
||||
'documentTemplateId'
|
||||
)
|
||||
useObjectNavigationTabPage({
|
||||
modelName: 'documentTemplate',
|
||||
pageName: 'design',
|
||||
objectId: documentTemplateId
|
||||
})
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'DocumentTemplateDesign',
|
||||
{
|
||||
|
||||
@ -97,6 +97,7 @@ const DocumentTemplateInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
|
||||
@ -88,7 +88,7 @@ const EmailAccountInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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 TemplatePreview from '../../common/TemplatePreview.jsx'
|
||||
|
||||
@ -74,7 +77,7 @@ const EmailMessageInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -16,6 +16,7 @@ import ObjectActions from '../../common/ObjectActions'
|
||||
import TemplateEditor from '../../common/TemplateEditor'
|
||||
import ScrollBox from '../../common/ScrollBox'
|
||||
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
|
||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const EmailTemplateDesign = () => {
|
||||
const location = useLocation()
|
||||
@ -24,6 +25,11 @@ const EmailTemplateDesign = () => {
|
||||
const emailTemplateId = new URLSearchParams(location.search).get(
|
||||
'emailTemplateId'
|
||||
)
|
||||
useObjectNavigationTabPage({
|
||||
modelName: 'emailTemplate',
|
||||
pageName: 'design',
|
||||
objectId: emailTemplateId
|
||||
})
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'EmailTemplateDesign',
|
||||
{ preview: true, editor: true, notes: true }
|
||||
|
||||
@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const log = loglevel.getLogger('EmailTemplateInfo')
|
||||
@ -92,7 +95,7 @@ const EmailTemplateInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -74,7 +74,7 @@ const FilamentSkuInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -82,14 +82,14 @@ const FilamentSkuInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='filamentSku'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={filamentSkuId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -190,7 +190,7 @@ const FilamentSkuInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={filamentSkuId} type='filamentSku' />
|
||||
<NotesPanel _id={filamentSkuId} type='filamentSku' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -209,10 +209,8 @@ const FilamentSkuInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('filamentSku').prefix +
|
||||
':' +
|
||||
filamentSkuId
|
||||
parent:
|
||||
getModelByName('filamentSku').prefix + ':' + filamentSkuId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -92,7 +92,7 @@ const FilamentInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -101,6 +101,7 @@ const FilamentInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -220,7 +221,9 @@ const FilamentInfo = () => {
|
||||
<ObjectTable
|
||||
ref={filamentSkusTableRef}
|
||||
type='filamentSku'
|
||||
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }}
|
||||
masterFilter={{
|
||||
filament: getModelByName('filament').prefix + ':' + filamentId
|
||||
}}
|
||||
visibleColumns={{ filament: false }}
|
||||
/>
|
||||
)}
|
||||
@ -238,7 +241,9 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='filamentStock'
|
||||
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }}
|
||||
masterFilter={{
|
||||
filament: getModelByName('filament').prefix + ':' + filamentId
|
||||
}}
|
||||
visibleColumns={{
|
||||
filament: false,
|
||||
startingWeight: false
|
||||
@ -259,7 +264,7 @@ const FilamentInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={filamentId} type='filament' />
|
||||
<NotesPanel _id={filamentId} type='filament' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -277,10 +282,7 @@ const FilamentInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('filament').prefix +
|
||||
':' +
|
||||
filamentId
|
||||
parent: getModelByName('filament').prefix + ':' + filamentId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -98,7 +98,7 @@ const FileInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -87,7 +87,7 @@ const HostInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current.handleUpdate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -96,6 +96,7 @@ const HostInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -213,7 +214,9 @@ const HostInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='printer'
|
||||
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }}
|
||||
masterFilter={{
|
||||
host: getModelByName('host').prefix + ':' + hostId
|
||||
}}
|
||||
visibleColumns={{
|
||||
host: false
|
||||
}}
|
||||
@ -234,7 +237,9 @@ const HostInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='documentPrinter'
|
||||
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }}
|
||||
masterFilter={{
|
||||
host: getModelByName('host').prefix + ':' + hostId
|
||||
}}
|
||||
visibleColumns={{
|
||||
host: false
|
||||
}}
|
||||
@ -254,7 +259,7 @@ const HostInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={hostId} type='host' />
|
||||
<NotesPanel _id={hostId} type='host' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -272,10 +277,7 @@ const HostInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('host').prefix +
|
||||
':' +
|
||||
hostId
|
||||
parent: getModelByName('host').prefix + ':' + hostId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -77,7 +77,7 @@ const MaterialInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,14 +85,14 @@ const MaterialInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='material'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={materialId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -193,7 +193,7 @@ const MaterialInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={materialId} type='material' />
|
||||
<NotesPanel _id={materialId} type='material' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -212,10 +212,7 @@ const MaterialInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('material').prefix +
|
||||
':' +
|
||||
materialId
|
||||
parent: getModelByName('material').prefix + ':' + materialId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -77,14 +77,14 @@ const NoteTypeInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='noteType'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={noteTypeId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -188,10 +188,7 @@ const NoteTypeInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('noteType').prefix +
|
||||
':' +
|
||||
noteTypeId
|
||||
parent: getModelByName('noteType').prefix + ':' + noteTypeId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -21,12 +21,16 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx'
|
||||
import ActionHandler from '../../common/ActionHandler.jsx'
|
||||
import ObjectActions from '../../common/ObjectActions.jsx'
|
||||
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
|
||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||
import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
||||
import UserNotifierToggle from '../../common/UserNotifierToggle.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')
|
||||
log.setLevel(config.logLevel)
|
||||
@ -36,6 +40,11 @@ const NoteInfo = () => {
|
||||
const objectFormRef = useRef(null)
|
||||
const actionHandlerRef = useRef(null)
|
||||
const noteId = new URLSearchParams(location.search).get('noteId')
|
||||
useObjectNavigationTabPage({
|
||||
modelName: 'note',
|
||||
pageName: 'info',
|
||||
objectId: noteId
|
||||
})
|
||||
const [collapseState, updateCollapseState] = useCollapseState('NoteInfo', {
|
||||
info: true,
|
||||
content: true,
|
||||
@ -77,7 +86,7 @@ const NoteInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,14 +94,14 @@ const NoteInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='note'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={noteId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -220,7 +229,7 @@ const NoteInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={noteId} type='note' />
|
||||
<NotesPanel _id={noteId} type='note' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -239,10 +248,7 @@ const NoteInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('note').prefix +
|
||||
':' +
|
||||
noteId
|
||||
parent: getModelByName('note').prefix + ':' + noteId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -69,7 +69,7 @@ const PartSkuInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -77,14 +77,14 @@ const PartSkuInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='partSku'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={partSkuId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -185,7 +185,7 @@ const PartSkuInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={partSkuId} type='partSku' />
|
||||
<NotesPanel _id={partSkuId} type='partSku' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -204,10 +204,7 @@ const PartSkuInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('partSku').prefix +
|
||||
':' +
|
||||
partSkuId
|
||||
parent: getModelByName('partSku').prefix + ':' + partSkuId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -88,7 +88,7 @@ const PartInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -197,7 +197,9 @@ const PartInfo = () => {
|
||||
<ObjectTable
|
||||
ref={partSkusTableRef}
|
||||
type='partSku'
|
||||
masterFilter={{ part: getModelByName('part').prefix + ':' + partId }}
|
||||
masterFilter={{
|
||||
part: getModelByName('part').prefix + ':' + partId
|
||||
}}
|
||||
visibleColumns={{ part: false }}
|
||||
/>
|
||||
)}
|
||||
@ -215,7 +217,7 @@ const PartInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={partId} type='part' />
|
||||
<NotesPanel _id={partId} type='part' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -232,10 +234,7 @@ const PartInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('part').prefix +
|
||||
':' +
|
||||
partId
|
||||
parent: getModelByName('part').prefix + ':' + partId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -80,7 +80,7 @@ const PermissionSettingInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -88,14 +88,14 @@ const PermissionSettingInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='permissionSetting'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={permissionSettingId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -227,10 +227,10 @@ const PermissionSettingInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel
|
||||
_id={permissionSettingId}
|
||||
type='permissionSetting'
|
||||
/>
|
||||
<NotesPanel
|
||||
_id={permissionSettingId}
|
||||
type='permissionSetting'
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
|
||||
@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const ProductCategoryInfo = () => {
|
||||
@ -77,7 +80,7 @@ const ProductCategoryInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,14 +88,14 @@ const ProductCategoryInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='productCategory'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={productCategoryId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -217,7 +220,7 @@ const ProductCategoryInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={productCategoryId} type='productCategory' />
|
||||
<NotesPanel _id={productCategoryId} type='productCategory' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -236,10 +239,10 @@ const ProductCategoryInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('productCategory').prefix +
|
||||
':' +
|
||||
productCategoryId
|
||||
parent:
|
||||
getModelByName('productCategory').prefix +
|
||||
':' +
|
||||
productCategoryId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -23,7 +23,10 @@ import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
||||
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
|
||||
import ScrollBox from '../../common/ScrollBox.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'
|
||||
|
||||
const ProductSkuInfo = () => {
|
||||
@ -75,7 +78,7 @@ const ProductSkuInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -83,14 +86,14 @@ const ProductSkuInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='productSku'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={productSkuId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -217,7 +220,7 @@ const ProductSkuInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={productSkuId} type='productSku' />
|
||||
<NotesPanel _id={productSkuId} type='productSku' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -236,10 +239,8 @@ const ProductSkuInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('productSku').prefix +
|
||||
':' +
|
||||
productSkuId
|
||||
parent:
|
||||
getModelByName('productSku').prefix + ':' + productSkuId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -88,7 +88,7 @@ const ProductInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -200,7 +200,10 @@ const ProductInfo = () => {
|
||||
<ObjectTable
|
||||
ref={productSkusTableRef}
|
||||
type='productSku'
|
||||
masterFilter={{ product: getModelByName('product').prefix + ':' + productId }}
|
||||
masterFilter={{
|
||||
product:
|
||||
getModelByName('product').prefix + ':' + productId
|
||||
}}
|
||||
visibleColumns={{ product: false }}
|
||||
/>
|
||||
)}
|
||||
@ -222,7 +225,7 @@ const ProductInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={productId} type='product' />
|
||||
<NotesPanel _id={productId} type='product' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -239,10 +242,7 @@ const ProductInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('product').prefix +
|
||||
':' +
|
||||
productId
|
||||
parent: getModelByName('product').prefix + ':' + productId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -23,6 +23,7 @@ import settingsSchema, {
|
||||
getVisibleSettingsSections,
|
||||
pickSettings
|
||||
} from '../../../database/Settings'
|
||||
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||
|
||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||
@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [
|
||||
]
|
||||
|
||||
const Settings = () => {
|
||||
useNavigationTabPage({ title: 'Settings', iconKey: 'settings' })
|
||||
const {
|
||||
isDarkMode,
|
||||
isCompact,
|
||||
|
||||
@ -22,7 +22,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const StockAuditLevelInfo = () => {
|
||||
@ -82,7 +85,7 @@ const StockAuditLevelInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -232,9 +235,7 @@ const StockAuditLevelInfo = () => {
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
|
||||
collapseKey='auditLogs'
|
||||
>
|
||||
{objectFormState.loading ? (
|
||||
|
||||
@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.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'
|
||||
|
||||
const log = loglevel.getLogger('TaxRateInfo')
|
||||
@ -77,7 +80,7 @@ const TaxRateInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,14 +88,14 @@ const TaxRateInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='taxRate'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={taxRateId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -217,7 +220,7 @@ const TaxRateInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={taxRateId} type='taxRate' />
|
||||
<NotesPanel _id={taxRateId} type='taxRate' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -236,10 +239,7 @@ const TaxRateInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('taxRate').prefix +
|
||||
':' +
|
||||
taxRateId
|
||||
parent: getModelByName('taxRate').prefix + ':' + taxRateId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -80,14 +80,14 @@ const UserGroupInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -243,7 +243,7 @@ const UserGroupInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={userGroupId} type='userGroup' />
|
||||
<NotesPanel _id={userGroupId} type='userGroup' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
|
||||
@ -86,7 +86,7 @@ const UserInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -264,7 +264,7 @@ const UserInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={userId} type='user' />
|
||||
<NotesPanel _id={userId} type='user' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
|
||||
@ -74,7 +74,7 @@ const VendorInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -82,14 +82,14 @@ const VendorInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='vendor'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={vendorId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -190,7 +190,7 @@ const VendorInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={vendorId} type='vendor' />
|
||||
<NotesPanel _id={vendorId} type='vendor' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -209,10 +209,7 @@ const VendorInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('vendor').prefix +
|
||||
':' +
|
||||
vendorId
|
||||
parent: getModelByName('vendor').prefix + ':' + vendorId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -111,14 +111,14 @@ const FilamentProfileInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify='space-between'>
|
||||
<Space size='small'>
|
||||
@ -529,7 +529,7 @@ const FilamentProfileInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={filamentProfileId} type='filamentProfile' />
|
||||
<NotesPanel _id={filamentProfileId} type='filamentProfile' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -547,10 +547,10 @@ const FilamentProfileInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('filamentProfile').prefix +
|
||||
':' +
|
||||
filamentProfileId
|
||||
parent:
|
||||
getModelByName('filamentProfile').prefix +
|
||||
':' +
|
||||
filamentProfileId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -25,7 +25,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
||||
import UserNotifierToggle from '../../common/UserNotifierToggle.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'
|
||||
|
||||
const log = loglevel.getLogger('GCodeFileInfo')
|
||||
@ -91,6 +94,7 @@ const GCodeFileInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -99,7 +103,7 @@ const GCodeFileInfo = () => {
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='gcodeFile'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={gcodeFileId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -230,7 +234,7 @@ const GCodeFileInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={gcodeFileId} type='gcodeFile' />
|
||||
<NotesPanel _id={gcodeFileId} type='gcodeFile' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -250,10 +254,8 @@ const GCodeFileInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('gcodeFile').prefix +
|
||||
':' +
|
||||
gcodeFileId
|
||||
parent:
|
||||
getModelByName('gcodeFile').prefix + ':' + gcodeFileId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -21,6 +21,7 @@ import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
|
||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
|
||||
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
|
||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const log = loglevel.getLogger('GCodeFilePreview')
|
||||
log.setLevel(config.logLevel)
|
||||
@ -29,6 +30,11 @@ const GCodeFilePreview = () => {
|
||||
const location = useLocation()
|
||||
const objectFormRef = useRef(null)
|
||||
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
|
||||
useObjectNavigationTabPage({
|
||||
modelName: 'gcodeFile',
|
||||
pageName: 'preview',
|
||||
objectId: gcodeFileId
|
||||
})
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'GCodeFilePreview',
|
||||
{
|
||||
|
||||
@ -97,6 +97,7 @@ const JobInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -224,7 +225,7 @@ const JobInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={jobId} type='job' />
|
||||
<NotesPanel _id={jobId} type='job' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -242,10 +243,7 @@ const JobInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('job').prefix +
|
||||
':' +
|
||||
jobId
|
||||
parent: getModelByName('job').prefix + ':' + jobId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -27,7 +27,10 @@ import UserNotifierToggle from '../../common/UserNotifierToggle'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import ObjectProperty from '../../common/ObjectProperty'
|
||||
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels'
|
||||
import {
|
||||
getModelProperty,
|
||||
getModelByName
|
||||
} from '../../../../database/ObjectModels'
|
||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
|
||||
import SettingsIcon from '../../../Icons/SettingsIcon'
|
||||
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
|
||||
@ -89,14 +92,14 @@ const PrinterProfileInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify='space-between'>
|
||||
<Space size='small'>
|
||||
@ -380,7 +383,7 @@ const PrinterProfileInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={printerProfileId} type='printerProfile' />
|
||||
<NotesPanel _id={printerProfileId} type='printerProfile' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -398,10 +401,10 @@ const PrinterProfileInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('printerProfile').prefix +
|
||||
':' +
|
||||
printerProfileId
|
||||
parent:
|
||||
getModelByName('printerProfile').prefix +
|
||||
':' +
|
||||
printerProfileId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -37,6 +37,7 @@ import ObjectTableNavigationButtons from '../../common/ObjectTableNavigationButt
|
||||
import PrinterControlButtons from '../../common/PrinterControlButtons.jsx'
|
||||
import PrinterQueue from '../../common/PrinterQueue.jsx'
|
||||
import ListIcon from '../../../Icons/ListIcon.jsx'
|
||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const log = loglevel.getLogger('ControlPrinter')
|
||||
log.setLevel(config.logLevel)
|
||||
@ -47,6 +48,11 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
||||
const actionHandlerRef = useRef(null)
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const printerId = new URLSearchParams(location.search).get('printerId')
|
||||
useObjectNavigationTabPage({
|
||||
modelName: 'printer',
|
||||
pageName: 'control',
|
||||
objectId: printerId
|
||||
})
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'ControlPrinter',
|
||||
{
|
||||
|
||||
@ -98,6 +98,7 @@ const PrinterInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -223,7 +224,9 @@ const PrinterInfo = () => {
|
||||
<ObjectTable
|
||||
ref={profilesTableRef}
|
||||
type='printerProfile'
|
||||
masterFilter={{ printer: getModelByName('printer').prefix + ':' + printerId }}
|
||||
masterFilter={{
|
||||
printer: getModelByName('printer').prefix + ':' + printerId
|
||||
}}
|
||||
visibleColumns={{
|
||||
printer: false,
|
||||
'printer._id': false
|
||||
@ -244,7 +247,7 @@ const PrinterInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={printerId} type='printer' />
|
||||
<NotesPanel _id={printerId} type='printer' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -262,10 +265,7 @@ const PrinterInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('printer').prefix +
|
||||
':' +
|
||||
printerId
|
||||
parent: getModelByName('printer').prefix + ':' + printerId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -77,14 +77,14 @@ const SubJobInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='subJob'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={subJobId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -183,7 +183,9 @@ const SubJobInfo = () => {
|
||||
) : (
|
||||
<ObjectTable
|
||||
type='stockEvent'
|
||||
masterFilter={{ owner: getModelByName('subJob').prefix + ':' + subJobId }}
|
||||
masterFilter={{
|
||||
owner: getModelByName('subJob').prefix + ':' + subJobId
|
||||
}}
|
||||
visibleColumns={{
|
||||
owner: false,
|
||||
'owner.type': false
|
||||
@ -203,7 +205,7 @@ const SubJobInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={subJobId} type='subJob' />
|
||||
<NotesPanel _id={subJobId} type='subJob' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -222,10 +224,7 @@ const SubJobInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('subJob').prefix +
|
||||
':' +
|
||||
subJobId
|
||||
parent: getModelByName('subJob').prefix + ':' + subJobId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -74,7 +74,7 @@ const ClientInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -82,14 +82,14 @@ const ClientInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Space size='small'>
|
||||
<ObjectActions
|
||||
type='client'
|
||||
pageName='info'
|
||||
pageName='info'
|
||||
id={clientId}
|
||||
disabled={objectFormState.loading}
|
||||
objectData={objectFormState.objectData}
|
||||
@ -190,7 +190,7 @@ const ClientInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={clientId} type='client' />
|
||||
<NotesPanel _id={clientId} type='client' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -209,10 +209,7 @@ const ClientInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('client').prefix +
|
||||
':' +
|
||||
clientId
|
||||
parent: getModelByName('client').prefix + ':' + clientId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -93,7 +93,7 @@ const FulfillmentPolicyInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -96,14 +96,14 @@ const ListingVarientInfo = () => {
|
||||
finishEdit: () => {
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
@ -256,7 +256,7 @@ const ListingVarientInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={listingVarientId} type='listingVarient' />
|
||||
<NotesPanel _id={listingVarientId} type='listingVarient' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
|
||||
@ -101,7 +101,7 @@ const ListingInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -162,7 +162,7 @@ const MarketplaceInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -93,7 +93,7 @@ const ReturnPolicyInfo = () => {
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ maxHeight: '100%', minHeight: 0 }}
|
||||
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
|
||||
@ -100,7 +100,7 @@ const SalesOrderInfo = () => {
|
||||
orderItemsTableRef?.current?.handleUpdate?.()
|
||||
objectFormRef?.current?.handleUpdate?.()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -109,6 +109,7 @@ const SalesOrderInfo = () => {
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
@ -228,7 +229,10 @@ const SalesOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='orderItem'
|
||||
masterFilter={{
|
||||
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
|
||||
order:
|
||||
getModelByName('salesOrder').prefix +
|
||||
':' +
|
||||
salesOrderId,
|
||||
orderType: 'salesOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -247,7 +251,10 @@ const SalesOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='shipment'
|
||||
masterFilter={{
|
||||
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
|
||||
order:
|
||||
getModelByName('salesOrder').prefix +
|
||||
':' +
|
||||
salesOrderId,
|
||||
orderType: 'salesOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -269,7 +276,10 @@ const SalesOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='invoice'
|
||||
masterFilter={{
|
||||
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
|
||||
order:
|
||||
getModelByName('salesOrder').prefix +
|
||||
':' +
|
||||
salesOrderId,
|
||||
orderType: 'salesOrder'
|
||||
}}
|
||||
visibleColumns={{ order: false }}
|
||||
@ -291,7 +301,10 @@ const SalesOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='stockEvent'
|
||||
masterFilter={{
|
||||
owner: getModelByName('salesOrder').prefix + ':' + salesOrderId
|
||||
owner:
|
||||
getModelByName('salesOrder').prefix +
|
||||
':' +
|
||||
salesOrderId
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -312,7 +325,7 @@ const SalesOrderInfo = () => {
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Card>
|
||||
<NotesPanel _id={salesOrderId} type='salesOrder' />
|
||||
<NotesPanel _id={salesOrderId} type='salesOrder' />
|
||||
</Card>
|
||||
</Spin>
|
||||
</InfoCollapse>
|
||||
@ -329,10 +342,8 @@ const SalesOrderInfo = () => {
|
||||
<ObjectTable
|
||||
type='auditLog'
|
||||
masterFilter={{
|
||||
parent:
|
||||
getModelByName('salesOrder').prefix +
|
||||
':' +
|
||||
salesOrderId
|
||||
parent:
|
||||
getModelByName('salesOrder').prefix + ':' + salesOrderId
|
||||
}}
|
||||
visibleColumns={{ _id: false, parent: false }}
|
||||
/>
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
// DashboardBreadcrumb.js
|
||||
import { useContext } from 'react'
|
||||
import { Breadcrumb, Button, Flex, Space } from 'antd'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
|
||||
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
|
||||
import FilterIcon from '../../Icons/FilterIcon'
|
||||
import { getModelByPluralName } from '../../../database/ObjectModels'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import { useTableState } from '../context/TableStateContext'
|
||||
import { useNavigationTabs } from '../context/NavigationTabsContext'
|
||||
|
||||
const breadcrumbNameMap = {
|
||||
production: 'Production',
|
||||
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
|
||||
const DashboardBreadcrumb = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { isElectron } = useContext(ElectronContext)
|
||||
const { goBack, goForward } = useNavigationTabs()
|
||||
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
|
||||
const pathSnippets = location.pathname.split('/').filter((i) => i)
|
||||
|
||||
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
|
||||
<Button
|
||||
type='text'
|
||||
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={() => (isElectron ? goBack() : navigate(-1))}
|
||||
style={{ padding: '0 2px', height: '22px' }}
|
||||
/>
|
||||
<Button
|
||||
type='text'
|
||||
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
|
||||
onClick={() => navigate(1)}
|
||||
onClick={() => (isElectron ? goForward() : navigate(1))}
|
||||
style={{ padding: '0 2px', height: '22px' }}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
21
src/components/Dashboard/common/DashboardChildRoutes.jsx
Normal file
21
src/components/Dashboard/common/DashboardChildRoutes.jsx
Normal 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>
|
||||
)
|
||||
70
src/components/Dashboard/common/DashboardDevControls.jsx
Normal file
70
src/components/Dashboard/common/DashboardDevControls.jsx
Normal 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
|
||||
@ -1,5 +1,13 @@
|
||||
// DashboardNavigation.js
|
||||
import { useContext, useEffect, useState, useMemo } from 'react'
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Menu,
|
||||
Flex,
|
||||
@ -11,7 +19,7 @@ import {
|
||||
Typography,
|
||||
Popover
|
||||
} from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import classNames from 'classnames'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SpotlightContext } from '../context/SpotlightContext'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
@ -35,11 +43,12 @@ import CloudIcon from '../../Icons/CloudIcon'
|
||||
import BellIcon from '../../Icons/BellIcon'
|
||||
import SearchIcon from '../../Icons/SearchIcon'
|
||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||
import DashboardDevControls from './DashboardDevControls'
|
||||
import WindowAppMenu from './WindowAppMenu'
|
||||
import WebAppSwitcher from './WebAppSwitcher'
|
||||
import DashboardTabs from './DashboardTabs'
|
||||
import {
|
||||
filterSidebarItemsByListPermission,
|
||||
getSidebarDefaultPath,
|
||||
@ -56,6 +65,246 @@ import { getEffectiveAppearance } from '../../../database/Settings'
|
||||
|
||||
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) =>
|
||||
items.map((item) => {
|
||||
if (item?.type === 'divider') {
|
||||
@ -82,8 +331,7 @@ const mapSidebarItemsToMenu = (items, navigate) =>
|
||||
const DashboardNavigation = () => {
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const { showSpotlight } = useContext(SpotlightContext)
|
||||
const { connecting, connected, userSettings, userSettingsLoaded } =
|
||||
useContext(ApiServerContext)
|
||||
const { userSettings, userSettingsLoaded } = useContext(ApiServerContext)
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const {
|
||||
showNavigationLabels,
|
||||
@ -93,7 +341,6 @@ const DashboardNavigation = () => {
|
||||
} = useThemeContext()
|
||||
const { toggleNotificationCenter, unreadCount } =
|
||||
useContext(NotificationContext)
|
||||
const [apiServerState, setApiServerState] = useState('disconnected')
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [selectedKey, setSelectedKey] = useState('production')
|
||||
@ -208,17 +455,18 @@ const DashboardNavigation = () => {
|
||||
[includeDevItems, location.pathname, selectedKey, userProfile]
|
||||
)
|
||||
|
||||
const headerMenuItems = isMobile
|
||||
? sidebarMenuItems
|
||||
: showNavigationLabels
|
||||
? mainMenuItems
|
||||
: mainMenuItems.map((item) => ({ ...item, label: undefined }))
|
||||
const headerMenuItems = useMemo(
|
||||
() =>
|
||||
isMobile
|
||||
? sidebarMenuItems
|
||||
: showNavigationLabels
|
||||
? mainMenuItems
|
||||
: mainMenuItems.map((item) => ({ ...item, label: undefined })),
|
||||
[isMobile, mainMenuItems, showNavigationLabels, sidebarMenuItems]
|
||||
)
|
||||
|
||||
const [userPopoverOpen, setUserPopoverOpen] = useState(false)
|
||||
|
||||
const userPopoverContent = (
|
||||
<UserProfilePopover onClose={() => setUserPopoverOpen(false)} />
|
||||
)
|
||||
const [anyModalVisible, setAnyModalVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
@ -228,19 +476,12 @@ const DashboardNavigation = () => {
|
||||
setSelectedKey(nextItem?.key || sectionKey)
|
||||
}, [location.pathname, mainMenuItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (connecting == true) {
|
||||
setApiServerState('connecting')
|
||||
} else if (connected == true) {
|
||||
setApiServerState('connected')
|
||||
} else {
|
||||
setApiServerState('disconnected')
|
||||
}
|
||||
}, [connecting, connected])
|
||||
|
||||
const handleMainMenuClick = ({ key }) => {
|
||||
navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
|
||||
}
|
||||
const handleMainMenuClick = useCallback(
|
||||
({ key }) => {
|
||||
navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
|
||||
},
|
||||
[includeDev, navigate, userProfile]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !setSidebarViewMenu) return
|
||||
@ -254,44 +495,141 @@ const DashboardNavigation = () => {
|
||||
const showDesktopLogo = !isElectron && !isMobile
|
||||
const showMobileLogo = !isElectron && isMobile
|
||||
|
||||
const showControls = !isElectron || authenticated
|
||||
useEffect(() => {
|
||||
if (!isElectron) {
|
||||
setAnyModalVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
const menu = (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels && !isMobile ? 'no-navigation-labels' : ''}${isMobile ? 'mobile-menu' : ''}`}
|
||||
items={headerMenuItems}
|
||||
style={{
|
||||
flexWrap: 'wrap',
|
||||
flexGrow: 1,
|
||||
border: 0,
|
||||
minWidth: 0
|
||||
}}
|
||||
onClick={isMobile ? undefined : handleMainMenuClick}
|
||||
selectedKeys={[isMobile ? selectedSidebarItemKey : selectedKey]}
|
||||
overflowedIndicator={
|
||||
<Button
|
||||
type='text'
|
||||
icon={<MenuIcon />}
|
||||
style={{ marginBottom: '4px' }}
|
||||
/>
|
||||
}
|
||||
const isModalWrapVisible = (node) => {
|
||||
if (!(node instanceof Element)) return false
|
||||
if (node.getAttribute('aria-hidden') === 'true') return false
|
||||
const style = window.getComputedStyle(node)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.pointerEvents !== 'none'
|
||||
)
|
||||
}
|
||||
|
||||
const updateModalVisibility = () => {
|
||||
const wraps = document.querySelectorAll('.ant-modal-wrap')
|
||||
setAnyModalVisible(Array.from(wraps).some(isModalWrapVisible))
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
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 = (
|
||||
<Flex style={{ width: '100%' }} align='center'>
|
||||
const navigationLeading = (
|
||||
<>
|
||||
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
||||
{isOtherApp ? (
|
||||
<>
|
||||
<WindowAppMenu />{' '}
|
||||
<Divider
|
||||
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}
|
||||
{showControls && isMobile && menu}
|
||||
{isMobile ? (
|
||||
<NavigationFade visible={showControls}>{menu}</NavigationFade>
|
||||
) : null}
|
||||
{showDesktopLogo == true ? (
|
||||
<FarmControlLogo
|
||||
style={{
|
||||
@ -308,146 +646,77 @@ const DashboardNavigation = () => {
|
||||
}}
|
||||
/>
|
||||
) : 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'>
|
||||
<Flex
|
||||
gap={'small'}
|
||||
align='center'
|
||||
style={{ marginTop: '-2px', marginRight: '6px' }}
|
||||
>
|
||||
{showControls && (
|
||||
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
|
||||
<WebAppSwitcher />
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+q'
|
||||
hint='ALT Q'
|
||||
onTrigger={() => showSpotlight()}
|
||||
>
|
||||
<Button
|
||||
icon={<SearchIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '4px' }}
|
||||
onClick={() => showSpotlight()}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
<Badge
|
||||
count={unreadCount}
|
||||
size='small'
|
||||
offset={[-5, 8]}
|
||||
style={{ padding: 0, fontWeight: 600 }}
|
||||
>
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+n'
|
||||
hint='ALT N'
|
||||
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>
|
||||
const showWindowTitle = isElectron && !showControls
|
||||
|
||||
const windowTitle = (
|
||||
<NavigationFade
|
||||
visible={showWindowTitle}
|
||||
className='dashboard-navigation-window-title'
|
||||
>
|
||||
<Text type='secondary'>Farm Control</Text>
|
||||
</NavigationFade>
|
||||
)
|
||||
|
||||
const navigationCenterBorder = (
|
||||
<div
|
||||
className={`dashboard-navigation-center-border${
|
||||
showControls ? ' dashboard-navigation-center-border-hidden' : ''
|
||||
}`}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)
|
||||
|
||||
const navigationContents = (
|
||||
<Flex style={{ width: '100%' }} align='center'>
|
||||
{navigationLeading}
|
||||
<div className='dashboard-navigation-center'>
|
||||
{!isMobile ? (
|
||||
<NavigationFade visible style={NAV_CENTER_FADE_STYLE}>
|
||||
{menu}
|
||||
</NavigationFade>
|
||||
) : null}
|
||||
{windowTitle}
|
||||
</div>
|
||||
{isOtherApp ? <DashboardWindowButtons /> : null}
|
||||
{navigationTrailing}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const electronDragClass =
|
||||
isFullScreen || (isMaximized && isOtherApp)
|
||||
? 'electrobun-webkit-app-region-no-drag'
|
||||
: 'electrobun-webkit-app-region-drag'
|
||||
|
||||
return (
|
||||
<>
|
||||
{isElectron ? (
|
||||
<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' }}
|
||||
>
|
||||
{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 vertical>
|
||||
|
||||
@ -25,6 +25,7 @@ import usePageLayout, {
|
||||
normalizeColumnProportions
|
||||
} from '../hooks/usePageLayout'
|
||||
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
|
||||
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||
|
||||
const flattenOverviewSections = (sectionList = []) => {
|
||||
const leaves = {}
|
||||
@ -124,6 +125,16 @@ const DashboardOverviewPage = ({
|
||||
onOpenActionsModal,
|
||||
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(
|
||||
pageName,
|
||||
collapseDefaults
|
||||
|
||||
@ -13,8 +13,8 @@ import { ElectronContext } from '../context/ElectronContext'
|
||||
import AdaptiveSplitter from './AdaptiveSplitter'
|
||||
|
||||
const COLLAPSE_BELOW_PX = 200
|
||||
const DEFAULT_SIDEBAR_PX = 300
|
||||
const MAX_SIDEBAR_PX = 400
|
||||
const DEFAULT_SIDEBAR_PX = 285
|
||||
const MAX_SIDEBAR_PX = 305
|
||||
const COLLAPSED_WIDTH_PX = 80
|
||||
const ELECTRON_COLLAPSED_WIDTH_PX = 55
|
||||
const EXPAND_DRAG_PX = 10
|
||||
|
||||
244
src/components/Dashboard/common/DashboardTabPanes.jsx
Normal file
244
src/components/Dashboard/common/DashboardTabPanes.jsx
Normal 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
|
||||
89
src/components/Dashboard/common/DashboardTabPreview.jsx
Normal file
89
src/components/Dashboard/common/DashboardTabPreview.jsx
Normal 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
|
||||
803
src/components/Dashboard/common/DashboardTabs.jsx
Normal file
803
src/components/Dashboard/common/DashboardTabs.jsx
Normal 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
|
||||
@ -6,11 +6,13 @@ import MinusIcon from '../../Icons/MinusIcon'
|
||||
import ContractIcon from '../../Icons/ContractIcon'
|
||||
import ExpandIcon from '../../Icons/ExpandIcon'
|
||||
import MacOSTrafficLights from './MacOSTrafficLights'
|
||||
import { useThemeContext } from '../context/ThemeContext'
|
||||
|
||||
const DashboardWindowButtons = () => {
|
||||
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
||||
useContext(ElectronContext)
|
||||
|
||||
const { showNavigationLabels } = useThemeContext()
|
||||
const closeButton = (
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
|
||||
<MacOSTrafficLights />
|
||||
<Divider
|
||||
type='vertical'
|
||||
style={{ height: '14px', margin: '3px 6px 0 0' }}
|
||||
style={{
|
||||
height: '14px',
|
||||
margin: showNavigationLabels ? '3px 6px 0 0' : '3px 0px 0 0'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -7,6 +7,8 @@ import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { getObjectIdFromSearch } from '../../../utils/modelActions'
|
||||
import { useActions } from '../context/ActionsContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { NavigationTabActiveContext } from '../context/NavigationTabsContext'
|
||||
import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const ModelPage = ({ modelName, pageName }) => {
|
||||
const model = getModelByName(modelName)
|
||||
@ -15,17 +17,35 @@ const ModelPage = ({ modelName, pageName }) => {
|
||||
const { setCurrentObject, setCurrentObjectType } = useActions()
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const objectId = getObjectIdFromSearch(modelName, location.search)
|
||||
const tabActiveStore = useContext(NavigationTabActiveContext)
|
||||
useObjectNavigationTabPage({ modelName, pageName, objectId })
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentObjectType(modelName)
|
||||
if (objectId) {
|
||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||
const sync = () => {
|
||||
if (!tabActiveStore.getSnapshot()) return
|
||||
setCurrentObjectType(modelName)
|
||||
if (objectId) {
|
||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
const unsubscribe = tabActiveStore.subscribe(sync)
|
||||
return () => {
|
||||
setCurrentObject(null)
|
||||
setCurrentObjectType(null)
|
||||
unsubscribe()
|
||||
if (tabActiveStore.getSnapshot()) {
|
||||
setCurrentObject(null)
|
||||
setCurrentObjectType(null)
|
||||
}
|
||||
}
|
||||
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile])
|
||||
}, [
|
||||
modelName,
|
||||
objectId,
|
||||
setCurrentObject,
|
||||
setCurrentObjectType,
|
||||
tabActiveStore,
|
||||
userProfile
|
||||
])
|
||||
|
||||
if (!page?.content) {
|
||||
return null
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
import { createElement } from 'react'
|
||||
import { createElement, memo } from 'react'
|
||||
import Thumbnail from './Thumbnail'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
const ObjectCard = ({
|
||||
const ObjectCard = memo(function ObjectCard({
|
||||
isSkeleton = false,
|
||||
model,
|
||||
modelProperties,
|
||||
@ -16,7 +16,7 @@ const ObjectCard = ({
|
||||
renderActions,
|
||||
lazyLoading = false,
|
||||
cardStyle = 'borderless'
|
||||
}) => {
|
||||
}) {
|
||||
const descriptionItems = []
|
||||
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
|
||||
|
||||
@ -169,7 +169,9 @@ const ObjectCard = ({
|
||||
</Flex>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ObjectCard.displayName = 'ObjectCard'
|
||||
|
||||
ObjectCard.propTypes = {
|
||||
model: PropTypes.object.isRequired,
|
||||
|
||||
@ -2,11 +2,11 @@ import PropTypes from 'prop-types'
|
||||
import { Typography, Flex, Badge, Tag, Popover } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { buildActionUrl } from '../../../utils/modelActions'
|
||||
import { findAction } from '../../../utils/modelActions'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useActions } from '../context/ActionsContext'
|
||||
import merge from 'lodash/merge'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import ElipsisText from './ElipsisText'
|
||||
@ -26,7 +26,7 @@ const ObjectDisplay = ({
|
||||
const { subscribeToObjectUpdates, connected, fetchSpotlightData } =
|
||||
useContext(ApiServerContext)
|
||||
const { token } = useContext(AuthContext)
|
||||
const navigate = useNavigate()
|
||||
const { callAction } = useActions()
|
||||
const idRef = useRef(null)
|
||||
|
||||
// Update event handler
|
||||
@ -175,17 +175,9 @@ const ObjectDisplay = ({
|
||||
const model = getModelByName(objectType)
|
||||
const Icon = model.icon
|
||||
const prefix = model.prefix
|
||||
|
||||
// Get hyperlink URL from model's default actions
|
||||
var hyperlink = null
|
||||
const defaultModelActions =
|
||||
model.actions?.filter((action) => action.default == true) || []
|
||||
const infoAction = findAction(model, 'info')
|
||||
const objectId = getStringId(objectData)
|
||||
|
||||
if (defaultModelActions.length >= 1 && objectId) {
|
||||
hyperlink = buildActionUrl(model, defaultModelActions[0], objectId, '', '')
|
||||
}
|
||||
|
||||
// Render name with spotlight support
|
||||
const renderNameDisplay = () => {
|
||||
if (!objectData?.name) return null
|
||||
@ -215,7 +207,12 @@ const ObjectDisplay = ({
|
||||
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 (
|
||||
<Tag
|
||||
@ -226,7 +223,7 @@ const ObjectDisplay = ({
|
||||
cursor: canNavigate ? 'pointer' : undefined
|
||||
}}
|
||||
className='object-display-tag'
|
||||
onClick={canNavigate ? () => navigate(hyperlink) : undefined}
|
||||
onClick={canNavigate ? handleNavigate : undefined}
|
||||
>
|
||||
<Flex
|
||||
gap={objectData?.color || objectData?.state ? 'small' : '5px'}
|
||||
|
||||
@ -14,6 +14,7 @@ import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
|
||||
import ObjectCard from './ObjectCard'
|
||||
import ObjectKanbanColumn from './ObjectKanbanColumn'
|
||||
import ObjectKanbanHeader from './ObjectKanbanHeader'
|
||||
@ -287,6 +288,7 @@ const ObjectKanban = forwardRef(
|
||||
subscribeToObjectTypeUpdates
|
||||
} = useContext(ApiServerContext)
|
||||
const { token } = useContext(AuthContext)
|
||||
const isTabActive = useIsNavigationTabActive()
|
||||
const columnRefs = useRef({})
|
||||
const headerTrackRef = useRef(null)
|
||||
const skeletonTrackRef = useRef(null)
|
||||
@ -720,7 +722,7 @@ const ObjectKanban = forwardRef(
|
||||
}, [connected, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (connected !== true || !type || !token) return
|
||||
if (connected !== true || !type || !token || !isTabActive) return
|
||||
|
||||
const unsubscribe = subscribeToAllObjectUpdates(
|
||||
type,
|
||||
@ -735,10 +737,10 @@ const ObjectKanban = forwardRef(
|
||||
subscribeToAllObjectUpdatesRef.current = null
|
||||
}
|
||||
}
|
||||
}, [type, connected, subscribeToAllObjectUpdates, token])
|
||||
}, [type, connected, subscribeToAllObjectUpdates, token, isTabActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (connected !== true || !token) return
|
||||
if (connected !== true || !token || !isTabActive) return
|
||||
if (subscribedTypeRef.current === type) return
|
||||
|
||||
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
||||
@ -757,7 +759,7 @@ const ObjectKanban = forwardRef(
|
||||
subscribedTypeRef.current = null
|
||||
}
|
||||
}
|
||||
}, [type, connected, token])
|
||||
}, [type, connected, token, isTabActive])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useEffect,
|
||||
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import { useActions } from '../context/ActionsContext'
|
||||
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
|
||||
import ActionsIcon from '../../Icons/ActionsIcon'
|
||||
import FilterIcon from '../../Icons/FilterIcon'
|
||||
import ScrollBox from './ScrollBox'
|
||||
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
|
||||
const SCROLL_THRESHOLD = 50
|
||||
const SKELETON_HEIGHT = 49.5
|
||||
const EMPTY_MASTER_FILTER = {}
|
||||
const EMPTY_VISIBLE_COLUMNS = {}
|
||||
|
||||
const getCardColSpan = (containerWidth) => {
|
||||
if (containerWidth >= 2980) return 2
|
||||
@ -291,8 +294,9 @@ EditableRow.propTypes = {
|
||||
onRegister: PropTypes.func
|
||||
}
|
||||
|
||||
const ObjectTable = forwardRef(
|
||||
(
|
||||
const ObjectTable = memo(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
type,
|
||||
pageSize = 25,
|
||||
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
|
||||
initialPage = 1,
|
||||
viewMode: viewModeProp,
|
||||
cards = false,
|
||||
visibleColumns = {},
|
||||
visibleColumns = EMPTY_VISIBLE_COLUMNS,
|
||||
masterFilter,
|
||||
size = 'middle',
|
||||
onStateChange,
|
||||
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
|
||||
const { token, userProfile } = useContext(AuthContext)
|
||||
const { isElectron } = useContext(ElectronContext)
|
||||
const { callAction } = useActions()
|
||||
const isTabActive = useIsNavigationTabActive()
|
||||
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
|
||||
const listViewId = objectListView?.listViewId ?? null
|
||||
const listViewFilter = objectListView?.listViewFilter ?? null
|
||||
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
|
||||
return {}
|
||||
}, [])
|
||||
|
||||
const rowActions =
|
||||
model.actions?.filter((action) => action.row == true) || []
|
||||
const rowActions = useMemo(
|
||||
() => 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(
|
||||
(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(
|
||||
async (pageNum = 1, filter = null, sorter = null) => {
|
||||
if (filter == null) {
|
||||
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
|
||||
|
||||
// Subscribe to all object updates for this type (list/cards only)
|
||||
useEffect(() => {
|
||||
if (isKanban || connected !== true || !type) return
|
||||
if (isKanban || connected !== true || !type || !isTabActive) return
|
||||
|
||||
const unsubscribe = subscribeToAllObjectUpdates(
|
||||
type,
|
||||
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
|
||||
subscribeToAllObjectUpdatesRef.current = null
|
||||
}
|
||||
}
|
||||
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
|
||||
}, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (isKanban || connected !== true) return
|
||||
if (isKanban || connected !== true || !isTabActive) return
|
||||
if (subscribedTypeRef.current === type) return
|
||||
|
||||
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
||||
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
|
||||
subscribedTypeRef.current = null
|
||||
}
|
||||
}
|
||||
}, [type, connected, isKanban])
|
||||
}, [type, connected, isKanban, isTabActive])
|
||||
|
||||
const updateData = useCallback(
|
||||
(id, updatedData) => {
|
||||
@ -1706,27 +1716,30 @@ const ObjectTable = forwardRef(
|
||||
return () => registerPageSorter({})
|
||||
}, [effectiveSorter, registerPageSorter, registerObjectListSorter])
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
visible,
|
||||
propertyName,
|
||||
propertyLabel
|
||||
}) => (
|
||||
<ColumnFilterDropdown
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
confirm={confirm}
|
||||
clearFilters={clearFilters}
|
||||
visible={visible}
|
||||
propertyName={propertyName}
|
||||
propertyLabel={propertyLabel}
|
||||
modelType={type}
|
||||
filter={effectiveFilter}
|
||||
masterFilter={resolvedMasterFilter}
|
||||
/>
|
||||
const getFilterDropdown = useCallback(
|
||||
({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
visible,
|
||||
propertyName,
|
||||
propertyLabel
|
||||
}) => (
|
||||
<ColumnFilterDropdown
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
confirm={confirm}
|
||||
clearFilters={clearFilters}
|
||||
visible={visible}
|
||||
propertyName={propertyName}
|
||||
propertyLabel={propertyLabel}
|
||||
modelType={type}
|
||||
filter={effectiveFilter}
|
||||
masterFilter={resolvedMasterFilter}
|
||||
/>
|
||||
),
|
||||
[effectiveFilter, resolvedMasterFilter, type]
|
||||
)
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
@ -1765,7 +1778,8 @@ const ObjectTable = forwardRef(
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
const effective = buildEffectiveFilter(next)
|
||||
@ -1858,66 +1872,7 @@ const ObjectTable = forwardRef(
|
||||
]
|
||||
)
|
||||
|
||||
const modelProperties = getModelProperties(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>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
const modelProperties = useMemo(() => getModelProperties(type), [type])
|
||||
|
||||
useEffect(() => {
|
||||
pagesRef.current = pages
|
||||
@ -1954,131 +1909,211 @@ const ObjectTable = forwardRef(
|
||||
tableData
|
||||
])
|
||||
|
||||
// Add columns in the order specified by model.columns
|
||||
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
|
||||
}
|
||||
const hasRealTableRows = tableData.some((item) => !item?.isSkeleton)
|
||||
|
||||
var fixed = prop.columnFixed || undefined
|
||||
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
|
||||
}
|
||||
// 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 }}
|
||||
const columnsWithSkeleton = useMemo(() => {
|
||||
const columns = [
|
||||
{
|
||||
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 (
|
||||
<ObjectProperty
|
||||
{...prop}
|
||||
longId={false}
|
||||
inTable={true}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (
|
||||
isFilterable &&
|
||||
!Object.keys(resolvedMasterFilter).includes(prop.name)
|
||||
) {
|
||||
columnConfig.filterDropdown = ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
visible
|
||||
}) =>
|
||||
getFilterDropdown({
|
||||
model.columns.forEach((colName) => {
|
||||
const prop = modelProperties.find((p) => p.name === colName)
|
||||
if (prop) {
|
||||
if (
|
||||
Object.keys(visibleColumns).length > 0 &&
|
||||
visibleColumns[prop.name] === false
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
var fixed = prop.columnFixed || undefined
|
||||
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,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
visible,
|
||||
propertyName: prop.name,
|
||||
propertyLabel: prop.label
|
||||
})
|
||||
columnConfig.filteredValue = fromFilterExpression(
|
||||
effectiveFilter[prop.name]
|
||||
)
|
||||
}
|
||||
visible
|
||||
}) =>
|
||||
getFilterDropdown({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
visible,
|
||||
propertyName: prop.name,
|
||||
propertyLabel: prop.label
|
||||
})
|
||||
columnConfig.filteredValue = fromFilterExpression(
|
||||
effectiveFilter[prop.name]
|
||||
)
|
||||
}
|
||||
|
||||
columnsWithSkeleton.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)
|
||||
columns.push(columnConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
const [cardColSpan, setCardColSpan] = useState(24)
|
||||
@ -2465,6 +2500,7 @@ const ObjectTable = forwardRef(
|
||||
</ObjectTableFilterContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
ObjectTable.displayName = 'ObjectTable'
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from 'react'
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import classNames from 'classnames'
|
||||
import { hasExternalTabDrag } from './tabDrag'
|
||||
|
||||
const SegmentedNavReorderContext = createContext(null)
|
||||
|
||||
@ -59,6 +60,9 @@ const SegmentedNav = ({
|
||||
options = [],
|
||||
onChange,
|
||||
onReorder,
|
||||
onExternalDrop,
|
||||
onItemDragStart,
|
||||
onItemDragEnd,
|
||||
className,
|
||||
disabled = false,
|
||||
animated = true
|
||||
@ -75,7 +79,10 @@ const SegmentedNav = ({
|
||||
const [dropTarget, setDropTarget] = useState(null)
|
||||
|
||||
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) => {
|
||||
if (element) {
|
||||
@ -172,21 +179,32 @@ const SegmentedNav = ({
|
||||
|
||||
setDraggedValue(optionValue)
|
||||
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(() => {
|
||||
onItemDragEnd?.()
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
}, [clearDragState, onItemDragEnd])
|
||||
|
||||
const handleItemDragOver = useCallback(
|
||||
(event, option) => {
|
||||
if (!onReorder || draggedValue == null || !option.reorderable) {
|
||||
const isExternal = acceptsExternal && hasExternalTabDrag(event)
|
||||
if ((!onReorder && !isExternal) || !option.reorderable) {
|
||||
return
|
||||
}
|
||||
if (String(option.value) === String(draggedValue)) {
|
||||
if (draggedValue == null && !isExternal) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
draggedValue != null &&
|
||||
String(option.value) === String(draggedValue)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -200,12 +218,13 @@ const SegmentedNav = ({
|
||||
insertBefore: event.clientX < rect.left + rect.width / 2
|
||||
})
|
||||
},
|
||||
[draggedValue, onReorder]
|
||||
[acceptsExternal, draggedValue, onReorder]
|
||||
)
|
||||
|
||||
const handleItemDrop = useCallback(
|
||||
(event, option) => {
|
||||
if (!onReorder || draggedValue == null || !option.reorderable) {
|
||||
const isExternal = acceptsExternal && draggedValue == null
|
||||
if ((!onReorder && !isExternal) || !option.reorderable) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -217,18 +236,39 @@ const SegmentedNav = ({
|
||||
insertBefore: true
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
onExternalDrop?.(target.value, target.insertBefore, event)
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (draggedValue == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (String(draggedValue) !== String(target.value)) {
|
||||
onReorder(draggedValue, target.value, target.insertBefore)
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
},
|
||||
[clearDragState, draggedValue, dropTarget, onReorder]
|
||||
[
|
||||
acceptsExternal,
|
||||
clearDragState,
|
||||
draggedValue,
|
||||
dropTarget,
|
||||
onExternalDrop,
|
||||
onReorder
|
||||
]
|
||||
)
|
||||
|
||||
const handleGroupDragOver = useCallback(
|
||||
(event) => {
|
||||
if (!onReorder || draggedValue == null) {
|
||||
const isExternal = acceptsExternal && hasExternalTabDrag(event)
|
||||
if (
|
||||
(!onReorder && !isExternal) ||
|
||||
(draggedValue == null && !isExternal)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -253,12 +293,16 @@ const SegmentedNav = ({
|
||||
insertBefore: false
|
||||
})
|
||||
},
|
||||
[draggedValue, onReorder, options]
|
||||
[acceptsExternal, draggedValue, onReorder, options]
|
||||
)
|
||||
|
||||
const handleGroupDrop = useCallback(
|
||||
(event) => {
|
||||
if (!onReorder || draggedValue == null) {
|
||||
const isExternal = acceptsExternal && draggedValue == null
|
||||
if (
|
||||
(!onReorder && !isExternal) ||
|
||||
(draggedValue == null && !isExternal)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -274,13 +318,31 @@ const SegmentedNav = ({
|
||||
|
||||
const reorderableOptions = options.filter((option) => option.reorderable)
|
||||
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)
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
},
|
||||
[clearDragState, draggedValue, onReorder, options]
|
||||
[
|
||||
acceptsExternal,
|
||||
clearDragState,
|
||||
draggedValue,
|
||||
onExternalDrop,
|
||||
onReorder,
|
||||
options
|
||||
]
|
||||
)
|
||||
|
||||
const getDragHandleProps = useCallback(
|
||||
@ -381,6 +443,13 @@ const SegmentedNav = ({
|
||||
isDropTarget && !dropTarget.insertBefore,
|
||||
'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={() => {
|
||||
if (!isDisabled) {
|
||||
onChange?.(option.value)
|
||||
@ -431,6 +500,9 @@ SegmentedNav.propTypes = {
|
||||
),
|
||||
onChange: PropTypes.func,
|
||||
onReorder: PropTypes.func,
|
||||
onExternalDrop: PropTypes.func,
|
||||
onItemDragStart: PropTypes.func,
|
||||
onItemDragEnd: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
animated: PropTypes.bool
|
||||
|
||||
@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useNavigationTabs } from '../context/NavigationTabsContext'
|
||||
import { useAppUpdateContext } from '../context/AppUpdateContext'
|
||||
import { getSidebarMenuSections } from '../../../database/Sidebars'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
||||
|
||||
const runEditCommand = (command) => {
|
||||
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
|
||||
const navigate = useNavigate()
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const { handleWindowControl } = useContext(ElectronContext)
|
||||
const { addTab, createNewWindow } = useNavigationTabs()
|
||||
const { checkForUpdates } = useAppUpdateContext()
|
||||
const includeDev = import.meta.env.DEV
|
||||
|
||||
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
|
||||
|
||||
const fileMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'new-window',
|
||||
label: 'New Window',
|
||||
onClick: () => createNewWindow()
|
||||
},
|
||||
{
|
||||
key: 'new-tab',
|
||||
label: 'New Tab',
|
||||
onClick: () => addTab()
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'close',
|
||||
label: 'Close Window',
|
||||
onClick: () => handleWindowControl('close')
|
||||
}
|
||||
],
|
||||
[handleWindowControl]
|
||||
[addTab, createNewWindow, handleWindowControl]
|
||||
)
|
||||
|
||||
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 (
|
||||
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
|
||||
)
|
||||
|
||||
59
src/components/Dashboard/common/tabDrag.js
Normal file
59
src/components/Dashboard/common/tabDrag.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
@ -20,52 +21,102 @@ import {
|
||||
stripModalActionParams
|
||||
} from '../../../utils/modelActions'
|
||||
import { AuthContext } from './AuthContext'
|
||||
import { ElectronContext } from './ElectronContext'
|
||||
import { useNavigationTabs } from './NavigationTabsContext'
|
||||
|
||||
const ActionsContext = createContext()
|
||||
|
||||
const ActionsProvider = ({ children }) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationRef = useRef(location)
|
||||
locationRef.current = location
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const [currentObject, setCurrentObject] = useState(null)
|
||||
const [currentObjectType, setCurrentObjectType] = useState(null)
|
||||
const [modalAction, setModalAction] = useState(null)
|
||||
const [onModalOk, setOnModalOk] = useState(null)
|
||||
const lastHandledAction = useRef(null)
|
||||
const { isElectron } = useContext(ElectronContext)
|
||||
const { addTab } = useNavigationTabs()
|
||||
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
const actionName = searchParams.get('action')
|
||||
const actionObjectType = searchParams.get('actionObjectType')
|
||||
|
||||
const clearAction = useCallback(() => {
|
||||
const nextUrl = stripModalActionParams(location.pathname, location.search)
|
||||
if (nextUrl !== location.pathname + location.search) {
|
||||
const loc = locationRef.current
|
||||
const nextUrl = stripModalActionParams(loc.pathname, loc.search)
|
||||
if (nextUrl !== loc.pathname + loc.search) {
|
||||
navigate(nextUrl, { replace: true })
|
||||
}
|
||||
setModalAction(null)
|
||||
lastHandledAction.current = null
|
||||
}, [location.pathname, location.search, navigate])
|
||||
}, [navigate])
|
||||
|
||||
const handleModalOk = useCallback(() => {
|
||||
onModalOk?.()
|
||||
clearAction()
|
||||
}, [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(
|
||||
(action, objectData, objectType) => {
|
||||
setCurrentObject(objectData)
|
||||
setCurrentObjectType(objectType)
|
||||
const model = getModelByName(objectType)
|
||||
const loc = locationRef.current
|
||||
const url = buildActionUrl(
|
||||
model,
|
||||
action,
|
||||
objectData._id,
|
||||
location.pathname,
|
||||
location.search
|
||||
loc.pathname,
|
||||
loc.search
|
||||
)
|
||||
navigate(url)
|
||||
handleNavigate(url)
|
||||
},
|
||||
[location.pathname, location.search, navigate]
|
||||
[handleNavigate]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -123,7 +174,7 @@ const ActionsProvider = ({ children }) => {
|
||||
if (action.name === 'list') {
|
||||
lastHandledAction.current = actionKey
|
||||
if (location.pathname !== model.url) {
|
||||
navigate(model.url, { replace: true })
|
||||
handleNavigate(model.url)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -147,7 +198,7 @@ const ActionsProvider = ({ children }) => {
|
||||
location.pathname,
|
||||
location.search
|
||||
)
|
||||
navigate(url, { replace: true })
|
||||
handleNavigate(url)
|
||||
}
|
||||
}, [
|
||||
actionName,
|
||||
@ -155,7 +206,7 @@ const ActionsProvider = ({ children }) => {
|
||||
currentObject,
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate
|
||||
handleNavigate
|
||||
])
|
||||
|
||||
const modalObjectData = currentObject
|
||||
@ -187,18 +238,22 @@ const ActionsProvider = ({ children }) => {
|
||||
? {}
|
||||
: modalObjectData
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currentObject,
|
||||
currentObjectType,
|
||||
setCurrentObject,
|
||||
setCurrentObjectType,
|
||||
callAction,
|
||||
clearAction,
|
||||
setOnModalOk,
|
||||
ctrlDown
|
||||
}),
|
||||
[callAction, clearAction, currentObject, currentObjectType, ctrlDown]
|
||||
)
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider
|
||||
value={{
|
||||
currentObject,
|
||||
currentObjectType,
|
||||
setCurrentObject,
|
||||
setCurrentObjectType,
|
||||
callAction,
|
||||
clearAction,
|
||||
setOnModalOk
|
||||
}}
|
||||
>
|
||||
<ActionsContext.Provider value={value}>
|
||||
<Modal
|
||||
open={resolvedModalAction != null}
|
||||
destroyOnHidden={true}
|
||||
|
||||
@ -5,7 +5,8 @@ import {
|
||||
useState,
|
||||
useContext,
|
||||
useRef,
|
||||
useCallback
|
||||
useCallback,
|
||||
useMemo
|
||||
} from 'react'
|
||||
import io from 'socket.io-client'
|
||||
import { message, Modal, Space, Button, Typography, Flex } from 'antd'
|
||||
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
|
||||
const getObjectEndpoint = (type) =>
|
||||
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 ApiServerProvider = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
token,
|
||||
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
|
||||
const subscribedActivityCallbacksRef = useRef(new Map())
|
||||
const subscribedActivityServerSubscriptionsRef = useRef(new Set())
|
||||
const notificationListenersRef = useRef(new Set())
|
||||
const completedLaunchSessionsRef = useRef(new Set())
|
||||
const [connectionIssue, setConnectionIssue] = useState(false)
|
||||
const [reconnectProgress, setReconnectProgress] = useState(0)
|
||||
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(
|
||||
RECONNECT_DELAYS_MS[0]
|
||||
)
|
||||
const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
|
||||
const [reconnectCycle, setReconnectCycle] = useState(0)
|
||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const reconnectTimerRef = useRef(null)
|
||||
const reconnectProgressTimerRef = useRef(null)
|
||||
const reconnectScheduledRef = useRef(false)
|
||||
const isReconnectingRef = useRef(false)
|
||||
const hasConnectedOnceRef = useRef(false)
|
||||
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
if (reconnectProgressTimerRef.current) {
|
||||
clearInterval(reconnectProgressTimerRef.current)
|
||||
reconnectProgressTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetReconnectState = useCallback(() => {
|
||||
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
|
||||
reconnectScheduledRef.current = false
|
||||
isReconnectingRef.current = false
|
||||
setIsReconnecting(false)
|
||||
setReconnectProgress(0)
|
||||
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
|
||||
setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
|
||||
}, [clearReconnectTimers])
|
||||
|
||||
const startReconnectCountdown = useCallback(
|
||||
(delayMs) => {
|
||||
clearReconnectTimers()
|
||||
const startedAt = Date.now()
|
||||
setReconnectRemainingMs(delayMs)
|
||||
setReconnectProgress(0)
|
||||
setReconnectDelayMs(delayMs)
|
||||
setReconnectCycle((current) => current + 1)
|
||||
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(() => {
|
||||
attemptReconnectRef.current?.()
|
||||
}, delayMs)
|
||||
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
|
||||
isReconnectingRef.current = true
|
||||
reconnectAttemptRef.current += 1
|
||||
setIsReconnecting(true)
|
||||
setReconnectProgress(100)
|
||||
setConnecting(true)
|
||||
setConnectionIssue(true)
|
||||
|
||||
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
|
||||
[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 response = await axios.get(
|
||||
`${config.backendUrl}/applaunch/${launchSession}`,
|
||||
@ -2924,167 +3104,120 @@ const ApiServerProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectSecondsRemaining = Math.max(
|
||||
1,
|
||||
Math.ceil(reconnectRemainingMs / 1000)
|
||||
)
|
||||
|
||||
// Sanitize a string so it is safe to use as a filename on most file systems
|
||||
const formatFileName = (name) => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Remove characters that are problematic on most common file systems
|
||||
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
|
||||
|
||||
// Normalize whitespace to single underscores
|
||||
const normalized = cleaned.trim().replace(/\s+/g, '_')
|
||||
|
||||
// Most file systems limit filenames to 255 characters
|
||||
return normalized.slice(0, 255)
|
||||
const apiMethodsRef = useRef({})
|
||||
apiMethodsRef.current = {
|
||||
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 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 (
|
||||
<ApiServerContext.Provider
|
||||
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
|
||||
}}
|
||||
>
|
||||
<ApiServerContext.Provider value={value}>
|
||||
{contextHolder}
|
||||
<LaunchSessionCompleter
|
||||
completeAppLaunchSession={completeAppLaunchSession}
|
||||
/>
|
||||
{children}
|
||||
<Modal
|
||||
title={
|
||||
!isReconnecting ? (
|
||||
<Space size={'middle'}>
|
||||
<ExclamationOctagonIcon />
|
||||
Connection Lost
|
||||
</Space>
|
||||
) : (
|
||||
false
|
||||
)
|
||||
}
|
||||
<ConnectionIssueModal
|
||||
open={Boolean(token) && authenticated == true && connectionIssue}
|
||||
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={() => 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>
|
||||
delayMs={reconnectDelayMs}
|
||||
cycle={reconnectCycle}
|
||||
isReconnecting={isReconnecting}
|
||||
onReconnect={attemptReconnect}
|
||||
/>
|
||||
<Modal
|
||||
title={
|
||||
<Space size={'middle'}>
|
||||
|
||||
@ -5,7 +5,8 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useContext,
|
||||
useRef
|
||||
useRef,
|
||||
useMemo
|
||||
} from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
@ -112,6 +113,8 @@ const AuthProvider = ({ children }) => {
|
||||
} = useContext(ElectronContext)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const locationRef = useRef(location)
|
||||
locationRef.current = location
|
||||
const sessionRef = useRef({
|
||||
token,
|
||||
expiresAt,
|
||||
@ -432,9 +435,11 @@ const AuthProvider = ({ children }) => {
|
||||
|
||||
// Login using query parameters
|
||||
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')
|
||||
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(nextRedirectUri)}`
|
||||
if (isElectron) {
|
||||
logger.debug('Opening external url...')
|
||||
openExternalUrl(loginUrl)
|
||||
@ -444,14 +449,7 @@ const AuthProvider = ({ children }) => {
|
||||
window.location.href = loginUrl
|
||||
}
|
||||
},
|
||||
[
|
||||
redirectType,
|
||||
messageApi,
|
||||
openExternalUrl,
|
||||
isElectron,
|
||||
location.search,
|
||||
location.pathname
|
||||
]
|
||||
[redirectType, messageApi, openExternalUrl, isElectron]
|
||||
)
|
||||
|
||||
const getLoginToken = useCallback(
|
||||
@ -461,7 +459,6 @@ const AuthProvider = ({ children }) => {
|
||||
setShowSessionExpiredModal(false)
|
||||
setAuthError(null)
|
||||
try {
|
||||
// Make a call to your backend to check auth status
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/auth/${redirectType}/token?code=${code}`
|
||||
)
|
||||
@ -480,7 +477,6 @@ const AuthProvider = ({ children }) => {
|
||||
setAuthenticated(true)
|
||||
setShowUnauthorizedModal(false)
|
||||
|
||||
// Persist session (cookies on web, electron storage on desktop)
|
||||
const persisted = await persistSession({
|
||||
token: nextToken,
|
||||
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')
|
||||
const newSearch = searchParams.toString()
|
||||
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
|
||||
const newPath = loc.pathname + (newSearch ? `?${newSearch}` : '')
|
||||
navigate(newPath, { replace: true })
|
||||
} else {
|
||||
setAuthenticated(false)
|
||||
@ -520,14 +517,7 @@ const AuthProvider = ({ children }) => {
|
||||
setRetreivedTokenFromCookies(true)
|
||||
}
|
||||
},
|
||||
[
|
||||
navigate,
|
||||
location.search,
|
||||
location.pathname,
|
||||
messageApi,
|
||||
persistSession,
|
||||
redirectType
|
||||
]
|
||||
[navigate, messageApi, persistSession, redirectType]
|
||||
)
|
||||
|
||||
// Function to check if the user is logged in
|
||||
@ -805,25 +795,39 @@ const AuthProvider = ({ children }) => {
|
||||
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 (
|
||||
<>
|
||||
{contextHolder}
|
||||
{notificationContextHolder}
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
authenticated,
|
||||
authInitialized: retreivedTokenFromCookies,
|
||||
setUnauthenticated,
|
||||
loginWithSSO,
|
||||
getLoginToken,
|
||||
token,
|
||||
loading,
|
||||
userProfile,
|
||||
setUserProfile,
|
||||
profileImageUrl,
|
||||
logout
|
||||
}}
|
||||
>
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
<Modal
|
||||
|
||||
@ -3,10 +3,12 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { NavigationTabActiveContext } from './NavigationTabsContext'
|
||||
|
||||
const DashboardObjectToolsContext = createContext()
|
||||
|
||||
@ -27,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
|
||||
setCurrentObjectToolsState(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currentObjectTools,
|
||||
setCurrentObjectTools,
|
||||
clearCurrentObjectTools
|
||||
}),
|
||||
[clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
|
||||
)
|
||||
|
||||
return (
|
||||
<DashboardObjectToolsContext.Provider
|
||||
value={{
|
||||
currentObjectTools,
|
||||
setCurrentObjectTools,
|
||||
clearCurrentObjectTools
|
||||
}}
|
||||
>
|
||||
<DashboardObjectToolsContext.Provider value={value}>
|
||||
{children}
|
||||
</DashboardObjectToolsContext.Provider>
|
||||
)
|
||||
@ -59,6 +64,7 @@ export const useDashboardObjectToolsContext = () => {
|
||||
export const useDashboardObjectTools = (tools) => {
|
||||
const { setCurrentObjectTools, clearCurrentObjectTools } =
|
||||
useDashboardObjectToolsContext()
|
||||
const store = useContext(NavigationTabActiveContext)
|
||||
const ownerIdRef = useRef(null)
|
||||
|
||||
if (ownerIdRef.current == null) {
|
||||
@ -66,8 +72,14 @@ export const useDashboardObjectTools = (tools) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||
}, [tools, setCurrentObjectTools])
|
||||
const sync = () => {
|
||||
if (!store.getSnapshot()) return
|
||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||
}
|
||||
|
||||
sync()
|
||||
return store.subscribe(sync)
|
||||
}, [setCurrentObjectTools, store, tools])
|
||||
|
||||
useEffect(() => {
|
||||
const ownerId = ownerIdRef.current
|
||||
|
||||
@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
|
||||
const [electronAvailable] = useState(isElectron())
|
||||
const navigate = useNavigate()
|
||||
const lastNavigationAtRef = useRef(0)
|
||||
const tabHistoryHandlerRef = useRef(null)
|
||||
|
||||
const registerTabHistoryHandler = useCallback((handler) => {
|
||||
tabHistoryHandlerRef.current =
|
||||
typeof handler === 'function' ? handler : null
|
||||
}, [])
|
||||
|
||||
const navigateHistory = useCallback(
|
||||
(direction) => {
|
||||
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
|
||||
if (now - lastNavigationAtRef.current < 300) return
|
||||
lastNavigationAtRef.current = now
|
||||
|
||||
if (tabHistoryHandlerRef.current) {
|
||||
tabHistoryHandlerRef.current(direction)
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'back') {
|
||||
navigate(-1)
|
||||
} else if (direction === 'forward') {
|
||||
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
|
||||
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 () => {
|
||||
if (!electronAvailable) return null
|
||||
return await desktopBridge.getAuthSession()
|
||||
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
|
||||
resizeSpotlightWindow,
|
||||
setSidebarViewMenu,
|
||||
getElectronVersion,
|
||||
getAppEngine
|
||||
getAppEngine,
|
||||
registerTabHistoryHandler,
|
||||
getWindowSession,
|
||||
syncWindowTabs,
|
||||
createAppWindow,
|
||||
onNewTabRequest,
|
||||
onNewWindowRequest,
|
||||
onTabMovedAway,
|
||||
beginTabDrag,
|
||||
completeTabDrop,
|
||||
cancelTabDrag
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { message } from 'antd'
|
||||
import { isElectrobunDesktop } from '../../../electrobun-bridge'
|
||||
@ -12,36 +12,52 @@ const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
|
||||
export const MessageProvider = ({ children }) => {
|
||||
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
|
||||
|
||||
const showMessage = (type, content, options = {}) => {
|
||||
return msgApi.open({
|
||||
type,
|
||||
content,
|
||||
...options
|
||||
})
|
||||
}
|
||||
const showMessage = useCallback(
|
||||
(type, content, options = {}) => {
|
||||
return msgApi.open({
|
||||
type,
|
||||
content,
|
||||
...options
|
||||
})
|
||||
},
|
||||
[msgApi]
|
||||
)
|
||||
|
||||
const showSuccess = (content, options = {}) =>
|
||||
showMessage('success', content, options)
|
||||
const showInfo = (content, options = {}) =>
|
||||
showMessage('info', content, options)
|
||||
const showWarning = (content, options = {}) =>
|
||||
showMessage('warning', content, options)
|
||||
const showError = (content, options = {}) =>
|
||||
showMessage('error', content, options)
|
||||
const showLoading = (content, options = {}) =>
|
||||
showMessage('loading', content, options)
|
||||
const showSuccess = useCallback(
|
||||
(content, options = {}) => showMessage('success', content, options),
|
||||
[showMessage]
|
||||
)
|
||||
const showInfo = useCallback(
|
||||
(content, options = {}) => showMessage('info', content, options),
|
||||
[showMessage]
|
||||
)
|
||||
const showWarning = useCallback(
|
||||
(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 (
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
msgApi,
|
||||
showSuccess,
|
||||
showInfo,
|
||||
showWarning,
|
||||
showError,
|
||||
showLoading
|
||||
}}
|
||||
>
|
||||
<MessageContext.Provider value={value}>
|
||||
{contextHolder}
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
|
||||
1208
src/components/Dashboard/context/NavigationTabsContext.jsx
Normal file
1208
src/components/Dashboard/context/NavigationTabsContext.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ import {
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@ -121,7 +122,14 @@ const NotificationProvider = ({ children }) => {
|
||||
|
||||
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(() => {
|
||||
if (!authenticated || !connected) return
|
||||
fetchNotificationsRef.current()
|
||||
@ -134,10 +142,6 @@ const NotificationProvider = ({ children }) => {
|
||||
fetchNotificationsRef.current()
|
||||
}, [notificationCenterVisible])
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationCenterVisible(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated || !registerNotificationListener) return
|
||||
const handleNotification = (notif) => {
|
||||
@ -169,28 +173,42 @@ const NotificationProvider = ({ children }) => {
|
||||
return unregister
|
||||
}, [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 (
|
||||
<NotificationContext.Provider
|
||||
value={{
|
||||
notificationCenterVisible,
|
||||
toggleNotificationCenter,
|
||||
notifications,
|
||||
fetchNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
deleteNotification,
|
||||
deleteAllNotifications,
|
||||
unreadCount,
|
||||
notificationsLoading
|
||||
}}
|
||||
>
|
||||
<NotificationContext.Provider value={value}>
|
||||
{contextHolder}
|
||||
{children}
|
||||
<Drawer
|
||||
title='Notifications'
|
||||
placement='right'
|
||||
width={isMobile ? '100%' : 460}
|
||||
onClose={() => setNotificationCenterVisible(false)}
|
||||
onClose={closeNotificationCenter}
|
||||
open={notificationCenterVisible}
|
||||
>
|
||||
<NotificationCenter
|
||||
|
||||
@ -14,6 +14,11 @@ import { AuthContext } from './AuthContext'
|
||||
import { useTableState } from './TableStateContext'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import {
|
||||
useNavigationTabPage,
|
||||
useNavigationTabs
|
||||
} from './NavigationTabsContext'
|
||||
|
||||
const ObjectListViewContext = createContext()
|
||||
|
||||
@ -126,7 +131,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
subscribeToObjectUpdates
|
||||
} = useContext(ApiServerContext)
|
||||
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 [draftViews, setDraftViews] = useState(() => {
|
||||
@ -194,6 +202,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
return view
|
||||
}, [activeTabKey, displayedViews])
|
||||
|
||||
const listLabel = listModel?.labelPlural || 'List'
|
||||
const viewName = activeView?.name
|
||||
useNavigationTabPage({
|
||||
title: viewName ? `${listLabel} - ${viewName}` : listLabel,
|
||||
modelName: objectType
|
||||
})
|
||||
|
||||
viewsStateRef.current = { views, draftViews, isEditing }
|
||||
activeViewRef.current = activeView
|
||||
|
||||
@ -288,11 +303,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
const name =
|
||||
key && view && typeof view === 'object' ? view.name || null : null
|
||||
|
||||
persistView(key)
|
||||
writeLastViewId(objectType, key)
|
||||
persistView(key, { scope: objectType, name })
|
||||
if (!isElectron) {
|
||||
writeLastViewId(objectType, key)
|
||||
}
|
||||
setObjectListView(objectType, key ? { id: key, name } : null)
|
||||
},
|
||||
[objectType, persistView, setObjectListView]
|
||||
[isElectron, objectType, persistView, setObjectListView]
|
||||
)
|
||||
|
||||
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
|
||||
if (initialLoading && views.length === 0) return
|
||||
|
||||
const urlViewId = getViewFromUrl()
|
||||
const rememberedViewId = readLastViewId(objectType)
|
||||
const candidateViewId = urlViewId || rememberedViewId
|
||||
const persistedViewId = getPersistedView(objectType)?.id || null
|
||||
const urlViewId = isElectron ? null : getViewFromUrl()
|
||||
const rememberedViewId = isElectron ? null : readLastViewId(objectType)
|
||||
const candidateViewId = persistedViewId || urlViewId || rememberedViewId
|
||||
|
||||
if (!candidateViewId) {
|
||||
urlSyncedRef.current = true
|
||||
@ -361,14 +379,18 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
if (matched) {
|
||||
const matchedId = String(matched._id)
|
||||
setActiveTabKey(matchedId)
|
||||
writeLastViewId(objectType, matchedId)
|
||||
if (!isElectron) {
|
||||
writeLastViewId(objectType, matchedId)
|
||||
}
|
||||
setObjectListView(objectType, {
|
||||
id: matchedId,
|
||||
name: matched.name || null
|
||||
})
|
||||
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
|
||||
setViewSyncReady(true)
|
||||
@ -391,6 +413,8 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
draftViews,
|
||||
isEditing,
|
||||
objectType,
|
||||
isElectron,
|
||||
getPersistedView,
|
||||
getViewFromUrl,
|
||||
persistView,
|
||||
rememberActiveView,
|
||||
@ -894,36 +918,33 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
||||
// route filter/sort changes into the draft instead of URL/session persistence.
|
||||
const activeObjectView = isEditing ? activeView : null
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
async (viewMode) => {
|
||||
const view = activeViewRef.current
|
||||
if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
|
||||
const handleViewModeChange = useCallback(async (viewMode) => {
|
||||
const view = activeViewRef.current
|
||||
if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
|
||||
|
||||
if (isEditingRef.current) {
|
||||
const updateInList = (list) =>
|
||||
list.map((item) =>
|
||||
String(item._id) === String(view._id) ? { ...item, viewMode } : item
|
||||
)
|
||||
if (isEditingRef.current) {
|
||||
const updateInList = (list) =>
|
||||
list.map((item) =>
|
||||
String(item._id) === String(view._id) ? { ...item, viewMode } : item
|
||||
)
|
||||
|
||||
activeViewRef.current = { ...view, viewMode }
|
||||
activeViewRef.current = { ...view, viewMode }
|
||||
|
||||
setDraftViews((prev) => updateInList(prev))
|
||||
viewsStateRef.current = {
|
||||
...viewsStateRef.current,
|
||||
draftViews: updateInList(viewsStateRef.current.draftViews)
|
||||
}
|
||||
return
|
||||
setDraftViews((prev) => updateInList(prev))
|
||||
viewsStateRef.current = {
|
||||
...viewsStateRef.current,
|
||||
draftViews: updateInList(viewsStateRef.current.draftViews)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Not editing: store as a temporary user override instead of saving
|
||||
setUserViewModeOverride(viewMode)
|
||||
userOverridesRef.current = {
|
||||
...(userOverridesRef.current || {}),
|
||||
viewMode
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
// Not editing: store as a temporary user override instead of saving
|
||||
setUserViewModeOverride(viewMode)
|
||||
userOverridesRef.current = {
|
||||
...(userOverridesRef.current || {}),
|
||||
viewMode
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isCustomView = activeTabKey !== ALL_TAB_KEY
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@ import {
|
||||
} from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
NavigationTabIdContext,
|
||||
useNavigationTabs
|
||||
} from './NavigationTabsContext'
|
||||
|
||||
const TableStateContext = createContext(null)
|
||||
|
||||
@ -166,6 +170,9 @@ const normalizeSorter = (sorter) => {
|
||||
}
|
||||
|
||||
export const TableStateProvider = ({ children }) => {
|
||||
const tabId = useContext(NavigationTabIdContext)
|
||||
const { isElectron, getTabListState, setTabListState, activeTab } =
|
||||
useNavigationTabs()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [pageFilters, setPageFilters] = useState({})
|
||||
const [pageSorters, setPageSorters] = useState({})
|
||||
@ -214,8 +221,17 @@ export const TableStateProvider = ({ children }) => {
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const resolveScopedTabId = useCallback(
|
||||
() => tabId || (isElectron ? activeTab?.id : null),
|
||||
[activeTab?.id, isElectron, tabId]
|
||||
)
|
||||
|
||||
const getPersistedFilter = useCallback(
|
||||
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
return getTabListState(scopedTabId, scope).filter || {}
|
||||
}
|
||||
if (useFilterInUrl) {
|
||||
const fromUrl = readFilterFromUrl(searchParams)
|
||||
if (fromUrl) return fromUrl
|
||||
@ -226,11 +242,15 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
return {}
|
||||
},
|
||||
[searchParams]
|
||||
[getTabListState, isElectron, resolveScopedTabId, searchParams]
|
||||
)
|
||||
|
||||
const getPersistedSorter = useCallback(
|
||||
(scope, { useSortInUrl = false, useSortInSession = false } = {}) => {
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
return getTabListState(scopedTabId, scope).sort || {}
|
||||
}
|
||||
if (useSortInUrl) {
|
||||
const fromUrl = readSortFromUrl(searchParams)
|
||||
if (fromUrl) return fromUrl
|
||||
@ -241,7 +261,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
return {}
|
||||
},
|
||||
[searchParams]
|
||||
[getTabListState, isElectron, resolveScopedTabId, searchParams]
|
||||
)
|
||||
|
||||
const getViewFromUrl = useCallback(
|
||||
@ -249,8 +269,36 @@ export const TableStateProvider = ({ children }) => {
|
||||
[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(
|
||||
(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) => {
|
||||
// Drop legacy `view` param that previously stored `_reference`
|
||||
next.delete('view')
|
||||
@ -266,7 +314,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
})
|
||||
},
|
||||
[updateSearchParams]
|
||||
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
|
||||
)
|
||||
|
||||
const persistFilter = useCallback(
|
||||
@ -276,6 +324,13 @@ export const TableStateProvider = ({ children }) => {
|
||||
{ saveFilterInSession = false, saveFilterInUrl = false } = {}
|
||||
) => {
|
||||
const active = getActiveFilterValues(filterState)
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
if (saveFilterInSession || saveFilterInUrl) {
|
||||
setTabListState(scopedTabId, scope, { filter: active })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (saveFilterInSession) {
|
||||
writeFilterToSession(scope, active)
|
||||
}
|
||||
@ -289,7 +344,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[updateSearchParams]
|
||||
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
|
||||
)
|
||||
|
||||
const persistSort = useCallback(
|
||||
@ -299,6 +354,13 @@ export const TableStateProvider = ({ children }) => {
|
||||
{ saveSortInSession = false, saveSortInUrl = false } = {}
|
||||
) => {
|
||||
const nextSorter = normalizeSorter(sorter)
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
if (saveSortInSession || saveSortInUrl) {
|
||||
setTabListState(scopedTabId, scope, { sort: nextSorter })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (saveSortInSession) {
|
||||
writeSortToSession(scope, nextSorter)
|
||||
}
|
||||
@ -312,7 +374,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[updateSearchParams]
|
||||
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
|
||||
)
|
||||
|
||||
const persistTableState = useCallback(
|
||||
@ -329,6 +391,21 @@ export const TableStateProvider = ({ children }) => {
|
||||
) => {
|
||||
const active = getActiveFilterValues(filterState)
|
||||
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) {
|
||||
writeFilterToSession(scope, active)
|
||||
@ -356,7 +433,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[updateSearchParams]
|
||||
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
|
||||
)
|
||||
|
||||
const setPageFilter = useCallback((path, filter) => {
|
||||
@ -389,7 +466,12 @@ export const TableStateProvider = ({ children }) => {
|
||||
const setObjectListFilter = useCallback((objectType, filter) => {
|
||||
if (!objectType) return
|
||||
const active = getActiveFilterValues(filter)
|
||||
writeListFilterToSession(objectType, active)
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
setTabListState(scopedTabId, objectType, { listFilter: active })
|
||||
} else {
|
||||
writeListFilterToSession(objectType, active)
|
||||
}
|
||||
setObjectListFilters((prev) => {
|
||||
const hasActive = Object.keys(active).length > 0
|
||||
if (!hasActive) {
|
||||
@ -400,12 +482,17 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
return { ...prev, [objectType]: active }
|
||||
})
|
||||
}, [])
|
||||
}, [isElectron, resolveScopedTabId, setTabListState])
|
||||
|
||||
const setObjectListSorter = useCallback((objectType, sorter) => {
|
||||
if (!objectType) return
|
||||
const nextSorter = normalizeSorter(sorter)
|
||||
writeListSortToSession(objectType, nextSorter)
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
setTabListState(scopedTabId, objectType, { listSort: nextSorter })
|
||||
} else {
|
||||
writeListSortToSession(objectType, nextSorter)
|
||||
}
|
||||
setObjectListSorters((prev) => {
|
||||
const hasSort = nextSorter?.field && nextSorter?.order
|
||||
if (!hasSort) {
|
||||
@ -416,7 +503,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
return { ...prev, [objectType]: nextSorter }
|
||||
})
|
||||
}, [])
|
||||
}, [isElectron, resolveScopedTabId, setTabListState])
|
||||
|
||||
const setObjectListView = useCallback((objectType, view) => {
|
||||
if (!objectType) return
|
||||
@ -424,7 +511,15 @@ export const TableStateProvider = ({ children }) => {
|
||||
view?.id != null
|
||||
? { id: String(view.id), name: view.name || 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) => {
|
||||
if (!nextView) {
|
||||
if (!(objectType in prev)) return prev
|
||||
@ -442,7 +537,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
}
|
||||
return { ...prev, [objectType]: nextView }
|
||||
})
|
||||
}, [])
|
||||
}, [isElectron, resolveScopedTabId, setTabListState])
|
||||
|
||||
const getObjectListFilter = useCallback(
|
||||
(objectType) => {
|
||||
@ -450,9 +545,15 @@ export const TableStateProvider = ({ children }) => {
|
||||
if (objectListFilters[objectType]) {
|
||||
return objectListFilters[objectType]
|
||||
}
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
return getActiveFilterValues(
|
||||
getTabListState(scopedTabId, objectType).listFilter || {}
|
||||
)
|
||||
}
|
||||
return getActiveFilterValues(readListFilterFromSession(objectType) || {})
|
||||
},
|
||||
[objectListFilters]
|
||||
[getTabListState, isElectron, objectListFilters, resolveScopedTabId]
|
||||
)
|
||||
|
||||
const getObjectListSorter = useCallback(
|
||||
@ -461,9 +562,13 @@ export const TableStateProvider = ({ children }) => {
|
||||
if (objectListSorters[objectType]) {
|
||||
return objectListSorters[objectType]
|
||||
}
|
||||
const scopedTabId = resolveScopedTabId()
|
||||
if (isElectron && scopedTabId) {
|
||||
return getTabListState(scopedTabId, objectType).listSort || {}
|
||||
}
|
||||
return readListSortFromSession(objectType) || {}
|
||||
},
|
||||
[objectListSorters]
|
||||
[getTabListState, isElectron, objectListSorters, resolveScopedTabId]
|
||||
)
|
||||
|
||||
const getObjectListView = useCallback(
|
||||
@ -472,9 +577,16 @@ export const TableStateProvider = ({ children }) => {
|
||||
if (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)
|
||||
},
|
||||
[objectListViews]
|
||||
[getTabListState, isElectron, objectListViews, resolveScopedTabId]
|
||||
)
|
||||
|
||||
const hasPageFilter = useCallback(
|
||||
@ -484,6 +596,17 @@ export const TableStateProvider = ({ children }) => {
|
||||
|
||||
const hasStoredFilter = useCallback((scope) => {
|
||||
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
|
||||
// already accounts for the view). Don't fall back to the All-tab session
|
||||
// filter — that belongs to a different context.
|
||||
@ -493,12 +616,19 @@ export const TableStateProvider = ({ children }) => {
|
||||
if (hasStoredSessionFilter(scope)) return true
|
||||
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
|
||||
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
|
||||
}, [objectListFilters, objectListViews])
|
||||
}, [
|
||||
getTabListState,
|
||||
isElectron,
|
||||
objectListFilters,
|
||||
objectListViews,
|
||||
resolveScopedTabId
|
||||
])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
getPersistedFilter,
|
||||
getPersistedSorter,
|
||||
getPersistedView,
|
||||
getViewFromUrl,
|
||||
persistView,
|
||||
persistFilter,
|
||||
@ -523,6 +653,7 @@ export const TableStateProvider = ({ children }) => {
|
||||
[
|
||||
getPersistedFilter,
|
||||
getPersistedSorter,
|
||||
getPersistedView,
|
||||
getViewFromUrl,
|
||||
persistView,
|
||||
persistFilter,
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect
|
||||
} from 'react'
|
||||
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
|
||||
)
|
||||
}, [showNavigationLabels])
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (isSystem) {
|
||||
setIsSystem(false)
|
||||
}
|
||||
setIsDarkMode(!isDarkMode)
|
||||
}
|
||||
const toggleTheme = useCallback(() => {
|
||||
setIsSystem((current) => {
|
||||
if (current) return false
|
||||
return current
|
||||
})
|
||||
setIsDarkMode((current) => !current)
|
||||
}, [])
|
||||
|
||||
const toggleSystem = () => {
|
||||
setIsSystem(!isSystem)
|
||||
if (!isSystem) {
|
||||
// When enabling system theme, update to match system preference
|
||||
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
}
|
||||
}
|
||||
const toggleSystem = useCallback(() => {
|
||||
setIsSystem((current) => {
|
||||
const next = !current
|
||||
if (next) {
|
||||
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleCompact = () => {
|
||||
setIsCompact(!isCompact)
|
||||
}
|
||||
const toggleCompact = useCallback(() => {
|
||||
setIsCompact((current) => !current)
|
||||
}, [])
|
||||
|
||||
const setThemeMode = useCallback((value) => {
|
||||
if (value === 'system') {
|
||||
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
|
||||
setShowNavigationLabelsState(Boolean(value))
|
||||
}, [])
|
||||
|
||||
const getThemeAlgorithm = () => {
|
||||
var baseAlgorithm
|
||||
if (isDarkMode == true) {
|
||||
baseAlgorithm = theme.darkAlgorithm
|
||||
} else {
|
||||
baseAlgorithm = theme.defaultAlgorithm
|
||||
}
|
||||
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
|
||||
}
|
||||
const getColors = useCallback(() => COLORS, [])
|
||||
|
||||
const getColors = () => {
|
||||
return COLORS
|
||||
}
|
||||
const themeConfig = useMemo(() => {
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -175,6 +223,10 @@ export const ThemeProvider = ({ children }) => {
|
||||
'--color-background',
|
||||
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-descriptions-border',
|
||||
@ -216,43 +268,14 @@ export const ThemeProvider = ({ children }) => {
|
||||
'--color-mobile-footer-item-border',
|
||||
isDarkMode ? '#303030' : '#f0f0f0'
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--color-header-border',
|
||||
isDarkMode ? 'rgba(253,253,253,0.12)' : 'rgba(10,10,10,0.12)'
|
||||
)
|
||||
}, [isDarkMode, primaryColorOverride])
|
||||
|
||||
const themeConfig = {
|
||||
algorithm: getThemeAlgorithm(),
|
||||
token: {
|
||||
...COLORS,
|
||||
colorPrimary:
|
||||
primaryColorOverride == null
|
||||
? COLORS.colorPrimary
|
||||
: primaryColorOverride,
|
||||
borderRadius: '12px'
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: isDarkMode ? '#141414' : '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
isDarkMode,
|
||||
toggleTheme,
|
||||
isCompact,
|
||||
toggleCompact,
|
||||
isSystem,
|
||||
toggleSystem,
|
||||
setThemeMode,
|
||||
setDensityMode,
|
||||
showNavigationLabels,
|
||||
setShowNavigationLabels,
|
||||
getColors,
|
||||
setPrimaryColorOverride,
|
||||
themeConfig
|
||||
}}
|
||||
>
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
|
||||
194
src/components/Dashboard/context/tabPreviewCapture.js
Normal file
194
src/components/Dashboard/context/tabPreviewCapture.js
Normal file
@ -0,0 +1,194 @@
|
||||
import { snapdom } from '@zumer/snapdom'
|
||||
|
||||
const FONT_TIMEOUT_MS = 180
|
||||
const CAPTURE_CLASS = 'dashboard-tab-pane-capturing'
|
||||
const CAPTURE_FRAME_CLASS = 'dashboard-tab-preview-capture-frame'
|
||||
const CAPTURE_PADDING_PX = 24
|
||||
|
||||
const PREVIEW_EXCLUDE = [
|
||||
'.ant-popover',
|
||||
'.dashboard-tab-preview-popover',
|
||||
'.dashboard-tab-preview'
|
||||
]
|
||||
|
||||
const waitForPaint = () =>
|
||||
new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
const waitForTimeout = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
const waitWithTimeout = (promise, ms) =>
|
||||
Promise.race([promise, waitForTimeout(ms)])
|
||||
|
||||
const getCssVariableColor = (name) =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
|
||||
const cssColorToRgb = (color, fallback) => {
|
||||
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
|
||||
return fallback
|
||||
}
|
||||
if (
|
||||
color.startsWith('#') ||
|
||||
color.startsWith('rgb(') ||
|
||||
color.startsWith('rgba(')
|
||||
) {
|
||||
return color
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1
|
||||
canvas.height = 1
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) return fallback
|
||||
ctx.fillStyle = fallback
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data
|
||||
if (!a) return fallback
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
const getPreviewBackgroundColor = (pane) => {
|
||||
const themeColor = getCssVariableColor('--tab-preview-render-background')
|
||||
if (themeColor) {
|
||||
return cssColorToRgb(themeColor, themeColor)
|
||||
}
|
||||
|
||||
return cssColorToRgb(
|
||||
(pane && window.getComputedStyle(pane).backgroundColor) ||
|
||||
window.getComputedStyle(document.body).backgroundColor,
|
||||
getCssVariableColor('--color-background') || '#ffffff'
|
||||
)
|
||||
}
|
||||
|
||||
const waitForFonts = async () => {
|
||||
if (!document.fonts?.ready) return
|
||||
await waitWithTimeout(document.fonts.ready, FONT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
export const waitForTabPreviewLayout = async (pane) => {
|
||||
if (!pane) return false
|
||||
|
||||
void pane.offsetHeight
|
||||
await waitForFonts()
|
||||
await waitForPaint()
|
||||
void pane.offsetHeight
|
||||
await waitForPaint()
|
||||
|
||||
return pane.clientWidth >= 8 && pane.clientHeight >= 8
|
||||
}
|
||||
|
||||
const resetCloneLayout = (element) => {
|
||||
const styles = element.style
|
||||
styles.position = 'relative'
|
||||
styles.inset = 'auto'
|
||||
styles.left = '0'
|
||||
styles.top = '0'
|
||||
styles.right = 'auto'
|
||||
styles.bottom = 'auto'
|
||||
styles.zIndex = '2'
|
||||
styles.display = 'flex'
|
||||
styles.flexDirection = 'column'
|
||||
styles.opacity = '1'
|
||||
styles.visibility = 'visible'
|
||||
styles.translate = 'none'
|
||||
styles.transform = 'none'
|
||||
styles.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const preparePaneForCapture = (pane) => {
|
||||
const previousInert = pane.inert
|
||||
const parent = pane.parentNode
|
||||
const nextSibling = pane.nextSibling
|
||||
const frame = document.createElement('div')
|
||||
|
||||
frame.className = CAPTURE_FRAME_CLASS
|
||||
frame.style.padding = `${CAPTURE_PADDING_PX}px`
|
||||
if (parent) {
|
||||
parent.insertBefore(frame, pane)
|
||||
frame.appendChild(pane)
|
||||
}
|
||||
|
||||
pane.classList.add(CAPTURE_CLASS)
|
||||
pane.inert = false
|
||||
void frame.offsetHeight
|
||||
void pane.offsetHeight
|
||||
|
||||
return {
|
||||
captureTarget: pane.parentNode === frame ? frame : pane,
|
||||
restore: () => {
|
||||
if (pane.parentNode === frame) {
|
||||
if (nextSibling && nextSibling.parentNode === parent) {
|
||||
parent.insertBefore(pane, nextSibling)
|
||||
} else if (parent) {
|
||||
parent.appendChild(pane)
|
||||
}
|
||||
}
|
||||
frame.remove()
|
||||
pane.classList.remove(CAPTURE_CLASS)
|
||||
pane.inert = previousInert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tabPreviewPlugin = {
|
||||
name: 'tab-preview-layout',
|
||||
afterClone(ctx) {
|
||||
const element = ctx.clone
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
const pane = element.classList.contains('dashboard-tab-pane')
|
||||
? element
|
||||
: element.querySelector('.dashboard-tab-pane')
|
||||
|
||||
resetCloneLayout(element)
|
||||
|
||||
if (!(pane instanceof HTMLElement)) return
|
||||
|
||||
pane.classList.remove('dashboard-tab-pane-inactive', CAPTURE_CLASS)
|
||||
pane.classList.add('dashboard-tab-pane-active')
|
||||
resetCloneLayout(pane)
|
||||
pane.style.flex = '1 1 auto'
|
||||
pane.style.width = '100%'
|
||||
pane.style.height = 'auto'
|
||||
pane.style.minHeight = '0'
|
||||
}
|
||||
}
|
||||
|
||||
export const captureTabPaneImage = async (pane) => {
|
||||
if (!pane) return null
|
||||
|
||||
const { captureTarget, restore } = preparePaneForCapture(pane)
|
||||
|
||||
try {
|
||||
const ready = await waitForTabPreviewLayout(pane)
|
||||
if (!ready) return null
|
||||
|
||||
const backgroundColor = getPreviewBackgroundColor(pane)
|
||||
const jpg = await snapdom.toJpg(captureTarget, {
|
||||
backgroundColor,
|
||||
scale: 1,
|
||||
embedFonts: 'auto',
|
||||
exclude: PREVIEW_EXCLUDE,
|
||||
excludeMode: 'remove',
|
||||
outerTransforms: false,
|
||||
placeholders: false,
|
||||
invalidate: true,
|
||||
plugins: [tabPreviewPlugin]
|
||||
})
|
||||
|
||||
return jpg?.src || null
|
||||
} catch (error) {
|
||||
console.error('Failed to capture tab preview', error)
|
||||
return null
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||
|
||||
const formatPageLabel = (pageName) =>
|
||||
pageName ? `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)}` : 'Page'
|
||||
|
||||
const getObjectDisplayName = (objectData, model) => {
|
||||
if (objectData?.name) return objectData.name
|
||||
if (objectData?._reference && model?.prefix) {
|
||||
return `${model.prefix}:${objectData._reference}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const formatObjectPageTabTitle = ({ pageName, objectName } = {}) => {
|
||||
const pageLabel = formatPageLabel(pageName)
|
||||
if (objectName) return `${objectName} - ${pageLabel}`
|
||||
return pageLabel
|
||||
}
|
||||
|
||||
export const useObjectNavigationTabPage = ({
|
||||
modelName,
|
||||
pageName,
|
||||
objectId
|
||||
} = {}) => {
|
||||
const model = getModelByName(modelName)
|
||||
const { fetchSpotlightData, subscribeToObjectUpdates, connected } =
|
||||
useContext(ApiServerContext)
|
||||
const { token } = useContext(AuthContext)
|
||||
const [objectName, setObjectName] = useState(null)
|
||||
const fetchSpotlightDataRef = useRef(fetchSpotlightData)
|
||||
fetchSpotlightDataRef.current = fetchSpotlightData
|
||||
|
||||
useEffect(() => {
|
||||
if (!objectId || !model?.prefix || token == null) {
|
||||
setObjectName(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const query = `${model.prefix}:${objectId}`
|
||||
|
||||
void fetchSpotlightDataRef.current(query).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
setObjectName(getObjectDisplayName(result, model))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [model, objectId, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!objectId || !modelName || !connected) return undefined
|
||||
return subscribeToObjectUpdates(objectId, modelName, (value) => {
|
||||
setObjectName((current) => getObjectDisplayName(value, model) || current)
|
||||
})
|
||||
}, [connected, model, modelName, objectId, subscribeToObjectUpdates])
|
||||
|
||||
useNavigationTabPage({
|
||||
title: formatObjectPageTabTitle({ pageName, objectName }),
|
||||
modelName
|
||||
})
|
||||
}
|
||||
|
||||
export default useObjectNavigationTabPage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user