@eintrek/erp-shell 0.1.57 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -778,10 +778,23 @@ function longestMatch(path, modules) {
778
778
  function getActiveModule(pathname, modules) {
779
779
  return longestMatch(pathBelowCode(pathname), modules)?.module.id ?? null;
780
780
  }
781
- /** Find the active sub-module id from `pathname`, by the same rule. */
781
+ /**
782
+ * Find the active sub-module id from `pathname`: the page whose own URL — or
783
+ * one of whose listed `subPages` — is the path itself (below the org code,
784
+ * ignoring a trailing slash). A record, a create or an edit page below a page
785
+ * is no page of the menu, so it has no active sub-module (0.1.58); the module
786
+ * (`getActiveModule`) still says where it belongs.
787
+ *
788
+ * Equal URLs in two modules go to the active module's page.
789
+ */
782
790
  function getActiveSubModule(pathname, modules) {
783
- // Null when the module's own URL is the best match: no page of it is.
784
- return longestMatch(pathBelowCode(pathname), modules)?.sub?.id ?? null;
791
+ const path = strip(pathBelowCode(pathname));
792
+ const isPage = (url) => isMenuPath(url) && strip(url) === path;
793
+ const pagesOf = (m) => (m.subModules ?? []).filter(sub => isPage(sub.url) || (sub.subPages ?? []).some(p => isPage(p.url)));
794
+ const moduleId = getActiveModule(pathname, modules);
795
+ const own = modules.find(m => m.id === moduleId);
796
+ const hit = (own ? pagesOf(own)[0] : undefined) ?? modules.flatMap(pagesOf)[0];
797
+ return hit?.id ?? null;
785
798
  }
786
799
  /** Prepend `code` (org code) to a relative URL so `/sales` → `/O25.../sales`.
787
800
  * Absolute `http(s)://` URLs are left intact; optional `{code}` placeholder is replaced.
@@ -804,100 +817,138 @@ function buildUrlWithCode(code, url) {
804
817
  return `${base}${query}${hash}`;
805
818
  }
806
819
  const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
820
+ /** A URL the path can be compared with: not query-only, not another site. */
821
+ const isMenuPath = (url) => !!url && !url.includes("?") && !/^https?:\/\//i.test(url);
807
822
  /** Same check as `isPathActive`: the page itself or one below it. */
808
823
  const pathIsAtOrBelow = (pathname, url) => pathname === url || pathname.startsWith(url + "/");
809
824
  /** "/O1/" and "/O1" are the same page; a query string never is. */
810
825
  function samePage(pathname, url) {
811
826
  if (url.includes("?"))
812
827
  return false;
813
- return strip(pathname) === strip(url);
828
+ return strip(pathname.split("?")[0]) === strip(url);
814
829
  }
815
830
  /**
816
- * How long the URL that makes `url` cover `pathname` is — or -1 when it does
817
- * not. The organisation's home ("/" → "/O1") covers only itself: every page
818
- * sits below it, and a dashboard is not the parent of every page.
831
+ * How long `url` is when it is the page `pathname` shows (ignoring a trailing
832
+ * slash), or -1. Compared with and without the org code, as the menu URLs
833
+ * are written either way.
819
834
  */
820
- function coverLength(pathname, url, code) {
835
+ function pageLength(pathname, url, code) {
836
+ if (!isMenuPath(url))
837
+ return -1;
821
838
  const built = buildUrlWithCode(code, url);
822
- const isRoot = url === "/" || (!!code && strip(built) === `/${code}`);
823
- if (isRoot) {
824
- return samePage(pathname, built) || samePage(pathname, url)
825
- ? strip(built).length
826
- : -1;
827
- }
828
- if (pathIsAtOrBelow(pathname, built))
839
+ if (samePage(pathname, built))
840
+ return strip(built).length;
841
+ if (samePage(pathname, url))
842
+ return strip(url).length;
843
+ return -1;
844
+ }
845
+ /**
846
+ * How long the URL that makes `url` cover `pathname` is — the page itself or
847
+ * one below it — or -1. The organisation's home ("/" → "/O1") covers only
848
+ * itself: every page sits below it, and a dashboard is not the parent of
849
+ * every page.
850
+ */
851
+ function coverLength(pathname, url, code) {
852
+ if (!isMenuPath(url))
853
+ return -1;
854
+ const path = strip(pathname.split("?")[0]);
855
+ const built = strip(buildUrlWithCode(code, url));
856
+ const isRoot = url === "/" || (!!code && built === `/${code}`);
857
+ if (isRoot)
858
+ return pageLength(path, url, code);
859
+ if (pathIsAtOrBelow(path, built))
829
860
  return built.length;
830
- if (pathIsAtOrBelow(pathname, url))
831
- return url.length;
861
+ const raw = strip(url);
862
+ if (pathIsAtOrBelow(path, raw))
863
+ return raw.length;
832
864
  return -1;
833
865
  }
834
866
  /**
835
- * Pick the single sidebar entry for `pathname`: the longest URL that covers
836
- * the path, across every group. Before 0.1.57 the longest match was taken in
837
- * each group separately, so /project/schedule lit both "โครงการ" (/project,
838
- * under โครงการ) and "ภาพรวมแผน" (/project/schedule, under แผนงาน).
867
+ * Where `pathname` sits in the sidebar: the entry to light and the group to
868
+ * open.
869
+ *
870
+ * Lit (0.1.58): only the entry whose own page this is — its URL or one of its
871
+ * `subPages`, exactly. A record, a create or an edit page below a menu page
872
+ * (Plannex /project/detail/<id>/schedule under "โครงการ" /project) lights
873
+ * nothing; an app lists such a page in `subPages` when it should keep its
874
+ * entry lit. At most one entry across every group (0.1.57).
875
+ *
876
+ * Open: the lit entry's group, or else the group with the deepest URL the
877
+ * path sits below.
839
878
  *
840
879
  * Equal lengths go to the group the app marks active (`item.isActive`, from
841
880
  * its `activeModule`), then to a page over a group row, then to the first in
842
881
  * menu order.
843
882
  */
844
- function resolveActiveNav(items, pathname, { code, workspace = false, highlightActiveModule = false } = {}) {
845
- const path = pathname ?? "";
883
+ function resolveSidebarNav(items, pathname, { code, highlightActiveModule = false } = {}) {
884
+ const path = (pathname ?? "").split("?")[0];
846
885
  // The app named a one-line module as the current one: that is the answer.
847
886
  if (highlightActiveModule) {
848
887
  const leaf = items.findIndex(item => !item.subModules?.length && !!item.isActive);
849
- if (leaf >= 0)
850
- return { item: leaf, sub: null, subPage: null };
851
- }
852
- let best = null;
853
- const consider = (c) => {
854
- if (!best) {
855
- best = c;
856
- return;
857
- }
858
- if (c.length !== best.length) {
859
- if (c.length > best.length)
860
- best = c;
861
- return;
888
+ if (leaf >= 0) {
889
+ return {
890
+ active: { item: leaf, sub: null, subPage: null },
891
+ open: { item: leaf, sub: null },
892
+ };
862
893
  }
894
+ }
895
+ const better = (c, best) => {
896
+ if (!best)
897
+ return true;
898
+ if (c.length !== best.length)
899
+ return c.length > best.length;
863
900
  const cActive = !!items[c.item].isActive;
864
901
  const bActive = !!items[best.item].isActive;
865
- if (cActive !== bActive) {
866
- if (cActive)
867
- best = c;
902
+ if (cActive !== bActive)
903
+ return cActive;
904
+ // The same sub-item by its own URL and by a listed page: the page.
905
+ if (c.item === best.item && c.sub === best.sub) {
906
+ return c.subPage !== null && best.subPage === null;
907
+ }
908
+ return c.sub !== null && best.sub === null;
909
+ };
910
+ let exact = null;
911
+ let within = null;
912
+ const consider = (c, isExact) => {
913
+ if (c.length < 0)
868
914
  return;
915
+ if (isExact) {
916
+ if (better(c, exact))
917
+ exact = c;
918
+ }
919
+ else if (better(c, within)) {
920
+ within = c;
869
921
  }
870
- if (c.sub !== null && best.sub === null)
871
- best = c;
872
922
  };
873
923
  items.forEach((item, i) => {
874
- const itemUrl = buildUrlWithCode(code, item.url);
875
924
  const subs = item.subModules ?? [];
925
+ const row = { item: i, sub: null, subPage: null };
876
926
  // The row itself: only on its own URL. A one-line item under
877
927
  // highlightActiveModule is lit by isActive alone (handled above).
878
928
  if (!(highlightActiveModule && subs.length === 0)) {
879
- const own = workspace ? samePage(path, itemUrl) : path === itemUrl;
880
- if (own)
881
- consider({ item: i, sub: null, subPage: null, length: strip(itemUrl).length });
929
+ consider({ ...row, length: pageLength(path, item.url, code) }, true);
882
930
  }
931
+ consider({ ...row, length: coverLength(path, item.url, code) }, false);
883
932
  subs.forEach((sub, j) => {
884
- // The sub-item counts by its own URL or by the longest of its pages.
885
- let subPage = null;
886
- let pageLength = -1;
933
+ const entry = { item: i, sub: j };
934
+ consider({ ...entry, subPage: null, length: pageLength(path, sub.url, code) }, true);
935
+ consider({ ...entry, subPage: null, length: coverLength(path, sub.url, code) }, false);
887
936
  (sub.subPages ?? []).forEach((page, k) => {
888
- const l = coverLength(path, page.url, code);
889
- if (l > pageLength) {
890
- pageLength = l;
891
- subPage = k;
892
- }
937
+ consider({ ...entry, subPage: k, length: pageLength(path, page.url, code) }, true);
938
+ consider({ ...entry, subPage: k, length: coverLength(path, page.url, code) }, false);
893
939
  });
894
- const length = Math.max(coverLength(path, sub.url, code), pageLength);
895
- if (length >= 0)
896
- consider({ item: i, sub: j, subPage, length });
897
940
  });
898
941
  });
899
- const found = best;
900
- return found ? { item: found.item, sub: found.sub, subPage: found.subPage } : null;
942
+ const lit = exact;
943
+ const holder = lit ?? within;
944
+ return {
945
+ active: lit ? { item: lit.item, sub: lit.sub, subPage: lit.subPage } : null,
946
+ open: holder ? { item: holder.item, sub: holder.sub } : null,
947
+ };
948
+ }
949
+ /** The entry lit for `pathname` — `resolveSidebarNav(...).active`. */
950
+ function resolveActiveNav(items, pathname, options = {}) {
951
+ return resolveSidebarNav(items, pathname, options).active;
901
952
  }
902
953
 
903
954
  function NavTools({ tools, closeOnNavigation = false }) {
@@ -1384,7 +1435,7 @@ function platformApiBase(app) {
1384
1435
  * This package's version, attached to help messages so the back office can
1385
1436
  * tell which shell a report came from. Keep in step with package.json.
1386
1437
  */
1387
- const SHELL_VERSION = "0.1.57";
1438
+ const SHELL_VERSION = "0.1.58";
1388
1439
 
1389
1440
  const HELP_APP_LABEL = {
1390
1441
  platform: "แพลตฟอร์ม",
@@ -1942,12 +1993,11 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1942
1993
  setOpen(false);
1943
1994
  };
1944
1995
  const code = orgCode ?? params?.code;
1945
- // The one entry that stands for this page — the longest matching URL
1946
- // across every group, not per group (which lit /project and
1947
- // /project/schedule together).
1948
- const active = resolveActiveNav(items, pathname, {
1996
+ // The one entry lit for this page — only on that entry's own page, never
1997
+ // on a record or a form below it — and the group holding the page, which
1998
+ // opens either way.
1999
+ const { active, open } = resolveSidebarNav(items, pathname, {
1949
2000
  code,
1950
- workspace,
1951
2001
  highlightActiveModule,
1952
2002
  });
1953
2003
  const states = items.map((item, i) => {
@@ -1957,36 +2007,32 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1957
2007
  itemUrl,
1958
2008
  activeSub: mine?.sub ?? null,
1959
2009
  activeSubPage: mine?.subPage ?? null,
1960
- hasActiveSubItem: mine?.sub != null,
2010
+ openSub: open?.item === i ? open.sub : null,
1961
2011
  isItemActive: !!mine && mine.sub === null,
1962
2012
  };
1963
2013
  });
1964
- // A group opens when its page becomes current and otherwise stays as the
2014
+ // A group opens when the page moves into it and otherwise stays as the
1965
2015
  // user left it. defaultOpen alone decided once, on the first render — and
1966
2016
  // on a direct load the sub-items arrive after permissions do, so the group
1967
2017
  // holding the current page stayed shut.
1968
- const activeKey = items
1969
- .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1970
- .map(item => item.id)
1971
- .join(",");
1972
- const [openIds, setOpenIds] = React__namespace.useState(() => new Set(activeKey ? activeKey.split(",") : []));
2018
+ const openId = open ? (items[open.item]?.id ?? "") : "";
2019
+ const [openIds, setOpenIds] = React__namespace.useState(() => new Set(openId ? [openId] : []));
1973
2020
  React__namespace.useEffect(() => {
1974
- if (!activeKey)
2021
+ if (!openId)
1975
2022
  return;
1976
2023
  setOpenIds(prev => {
1977
- const ids = activeKey.split(",");
1978
- if (ids.every(id => prev.has(id)))
2024
+ if (prev.has(openId))
1979
2025
  return prev;
1980
2026
  const next = new Set(prev);
1981
- ids.forEach(id => next.add(id));
2027
+ next.add(openId);
1982
2028
  return next;
1983
2029
  });
1984
- }, [activeKey]);
1985
- const setItemOpen = (id, open) => setOpenIds(prev => {
1986
- if (prev.has(id) === open)
2030
+ }, [openId]);
2031
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2032
+ if (prev.has(id) === isOpen)
1987
2033
  return prev;
1988
2034
  const next = new Set(prev);
1989
- if (open)
2035
+ if (isOpen)
1990
2036
  next.add(id);
1991
2037
  else
1992
2038
  next.delete(id);
@@ -1997,7 +2043,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1997
2043
  item.group &&
1998
2044
  item.group !== items[index - 1]?.group ? (jsxRuntime.jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1999
2045
  (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
2000
- const { itemUrl, activeSub, activeSubPage, isItemActive } = states[index];
2046
+ const { itemUrl, activeSub, activeSubPage, openSub, isItemActive } = states[index];
2001
2047
  const IconComp = item.icon;
2002
2048
  const workspaceButton = workspace
2003
2049
  ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-xs " +
@@ -2007,7 +2053,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2007
2053
  : "";
2008
2054
  const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
2009
2055
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-xs leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
2010
- return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2056
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2011
2057
  workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2012
2058
  (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
2013
2059
  ? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
@@ -2021,7 +2067,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2021
2067
  if (subItem.isCollapsible &&
2022
2068
  subItem.subPages &&
2023
2069
  subItem.subPages.length > 0) {
2024
- return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2070
+ return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2025
2071
  const subPageUrl = buildUrlWithCode(code, subPage.url);
2026
2072
  const isSubPageActive = isSubItemActive &&
2027
2073
  activeSubPage === subPageIndex;
@@ -3810,6 +3856,7 @@ exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRule
3810
3856
  exports.openHelp = openHelp;
3811
3857
  exports.parseMyRoles = parseMyRoles;
3812
3858
  exports.resolveActiveNav = resolveActiveNav;
3859
+ exports.resolveSidebarNav = resolveSidebarNav;
3813
3860
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
3814
3861
  exports.roleSummary = roleSummary;
3815
3862
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;