@eintrek/erp-shell 0.1.59 → 0.1.61

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.
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { type ShellChrome, type WorkspaceHeaderTone } from "./chrome";
3
3
  import { type HelpAppId } from "../help/help-store";
4
+ import { type OrganizationsStatus } from "./url-org-gate";
4
5
  /**
5
6
  * Shape the shell needs from an organization to wire the URL `[code]` segment
6
7
  * back to the active org id. Hosts already keep richer types around — this is
@@ -40,6 +41,20 @@ export interface AppLayoutShellProps {
40
41
  setOrganizationId: (id: string | number | null) => Promise<void> | void;
41
42
  /** Orgs the user belongs to — used to resolve URL `[code]` → org `id`. */
42
43
  organizations: AppLayoutShellOrganization[];
44
+ /**
45
+ * `status` of the query behind `organizations`. With it the page waits for
46
+ * the list before it mounts, and a code that is not among the user's
47
+ * organisations is refused. Without it the gate starts only once the list
48
+ * is non-empty, so requests made while it loads carry the previous
49
+ * organisation. Every host should pass it.
50
+ */
51
+ organizationsStatus?: OrganizationsStatus;
52
+ /**
53
+ * Shown in the content area when the URL's code is not one of the user's
54
+ * organisations. Defaults to AccessDenied with a way to pick an
55
+ * organisation.
56
+ */
57
+ notMemberComponent?: React.ReactNode;
43
58
  /** Absolute path to redirect to when the session has no accessToken (e.g.
44
59
  * token refresh failed). The shell appends `?error=NoAccessToken&callbackUrl=…`. */
45
60
  signInUrl: string;
@@ -84,4 +99,4 @@ export interface AppLayoutShellProps {
84
99
  * an erp-shell bump — domain components must NOT add their own root padding
85
100
  * or content gets indented twice.
86
101
  */
87
- export declare function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome, workspaceHeader, headerEnd, mobileBrand, showHelp, helpApp, }: AppLayoutShellProps): import("react/jsx-runtime").JSX.Element;
102
+ export declare function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome, workspaceHeader, headerEnd, mobileBrand, showHelp, helpApp, }: AppLayoutShellProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import type { Module } from "./navigation-types";
2
+ /**
3
+ * How a sidebar row looks, apart from its layout.
4
+ *
5
+ * Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
6
+ * row under the pointer took the lit row's fill and colour, and an open group
7
+ * (or a one-line row once clicked, which was a collapsible too) took its
8
+ * colour, so the menu showed several "current pages" at once.
9
+ */
10
+ /** A row under the pointer: a faint neutral tint, never the lit row's look. */
11
+ export declare const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
12
+ /** The lit row: the accent fill and text. The pointer changes nothing. */
13
+ export declare const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
14
+ /** Fill and colour for a row, lit or not. */
15
+ export declare function navRowTone(lit: boolean): string;
16
+ /**
17
+ * A group opens and shuts; any other row is a plain link with no open state.
18
+ */
19
+ export declare function isNavGroup(item: Pick<Module, "subModules">): boolean;
20
+ /**
21
+ * The open groups after `id` is opened or shut. An id that is not a group's
22
+ * is ignored — a one-line row has nothing to open.
23
+ */
24
+ export declare function setNavGroupOpen(openIds: ReadonlySet<string>, items: ReadonlyArray<Pick<Module, "id" | "subModules">>, id: string, isOpen: boolean): ReadonlySet<string>;
@@ -16,6 +16,11 @@ export declare const SidebarMenuItem: React.ForwardRefExoticComponent<Omit<React
16
16
  export declare const SidebarMenuButton: React.ForwardRefExoticComponent<Omit<React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & {
17
17
  asChild?: boolean;
18
18
  isActive?: boolean;
19
+ /**
20
+ * The row's name beside it, for a row that shows only its icon — the
21
+ * theme's tooltip on the right, never a native `title` (which the
22
+ * browser drops wherever it likes, over the logo included).
23
+ */
19
24
  tooltip?: string;
20
25
  size?: "sm" | "default" | "lg";
21
26
  }, "ref"> & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What AppLayoutShell does with the page while the organisation in the URL and
3
+ * the tab's active organisation are being reconciled. Pure, so it is tested
4
+ * without React (url-org-gate.test.ts).
5
+ *
6
+ * The URL's `[code]` is authoritative. The page — and with it every request
7
+ * the page makes — mounts only once the active organisation IS the one the
8
+ * URL names:
9
+ *
10
+ * ready the active organisation is the URL's: render the page.
11
+ * resolving the caller's organisations are still loading, so the code
12
+ * cannot be resolved yet: hold the page.
13
+ * switching the code is one of the caller's organisations but not the
14
+ * active one: switch to it (`targetId`), hold the page meanwhile.
15
+ * not-member the list loaded and the code is not in it: refuse.
16
+ * unverified the list could not be loaded: render as before the gate
17
+ * existed (the API still scopes every request), rather than
18
+ * leave the page on a loader for good.
19
+ */
20
+ export interface UrlOrgGateOrganization {
21
+ id: string | number;
22
+ code?: string | null;
23
+ }
24
+ /** react-query's `status` of the host's organisations query. */
25
+ export type OrganizationsStatus = "pending" | "success" | "error";
26
+ export type UrlOrgGate = {
27
+ state: "ready";
28
+ targetId: string | null;
29
+ } | {
30
+ state: "resolving";
31
+ } | {
32
+ state: "switching";
33
+ targetId: string;
34
+ } | {
35
+ state: "not-member";
36
+ } | {
37
+ state: "unverified";
38
+ };
39
+ export interface UrlOrgGateInput {
40
+ /** The URL's `[code]` segment. */
41
+ code: string | undefined;
42
+ /** The caller's organisations. */
43
+ organizations: readonly UrlOrgGateOrganization[];
44
+ /**
45
+ * Status of the query that produced `organizations`. A host that does not
46
+ * pass it gets the gate only once the list is non-empty: an empty list then
47
+ * cannot be told apart from "still loading" or "failed", and holding the
48
+ * page on that would strand a host that never loads one.
49
+ */
50
+ organizationsStatus?: OrganizationsStatus;
51
+ /** The active organisation (the host's ERP context). */
52
+ organizationId: string | number | null | undefined;
53
+ }
54
+ /** The organisation a `[code]` segment names, among the caller's. */
55
+ export declare function findOrganizationForCode<T extends UrlOrgGateOrganization>(code: string | undefined, organizations: readonly T[]): T | null;
56
+ export declare function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }: UrlOrgGateInput): UrlOrgGate;
57
+ /**
58
+ * What to do when the active organisation is not one of the user's — a
59
+ * browser that remembers an organisation that was deleted, or that the user
60
+ * has left. Pure, tested in url-org-gate.test.ts.
61
+ *
62
+ * none nothing to recover: the list has not loaded, there is no active
63
+ * organisation, or the active one is the user's.
64
+ * use make `targetId` active: the URL's organisation when the user is
65
+ * a member of it, otherwise the user's only organisation.
66
+ * forget clear it; the user has several organisations and the URL names
67
+ * none of them, so they pick one.
68
+ */
69
+ export type StaleOrganizationRecovery = {
70
+ action: "none";
71
+ } | {
72
+ action: "use";
73
+ targetId: string;
74
+ fromUrl: boolean;
75
+ } | {
76
+ action: "forget";
77
+ };
78
+ export declare function resolveStaleOrganization({ code, organizations, organizationsStatus, organizationId, }: UrlOrgGateInput): StaleOrganizationRecovery;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The organisation this browser tab is working in.
3
+ *
4
+ * Every request carries an X-Organization-ID header, and every cached query
5
+ * belongs to one organisation. Both used to come from the `erp.organizationId`
6
+ * cookie (then localStorage), which is the organisation the BROWSER last had
7
+ * active — shared by every tab and, on antniti.com, by every app. A tab
8
+ * showing /O26000021/… therefore sent whatever another tab, or another app,
9
+ * had picked last, and the URL lost.
10
+ *
11
+ * The tab's own value is set by `useOrganizationIdState` — the state that
12
+ * AppLayoutShell drives to the organisation the URL names — and read first by
13
+ * `getRequestOrganizationId()`. Until a tab has one (a cold load before the
14
+ * provider mounts), localStorage and then the cookie answer as before.
15
+ *
16
+ * Module state, so browser-only: on the server one module instance serves
17
+ * every request, and a value written there would leak between users.
18
+ */
19
+ /** The organisation this tab is working in, or null before it has one. */
20
+ export declare function getTabOrganizationId(): string | null;
21
+ /** Record the organisation this tab is working in (browser only). */
22
+ export declare function setTabOrganizationId(id: string | number | null | undefined): void;
23
+ /**
24
+ * Record the user's organisations (browser only). From then on a stored or
25
+ * tab organisation that is not among them loses to one that is: a browser
26
+ * that still remembers a deleted organisation — or one the user has left —
27
+ * must not keep sending it while the cookie names a good one.
28
+ */
29
+ export declare function setKnownOrganizationIds(ids: Iterable<string | number> | null): void;
30
+ /** Whether the loaded list says the user is NOT a member of `id`. */
31
+ export declare function isKnownStaleOrganizationId(id: string | number | null | undefined): boolean;
32
+ /**
33
+ * Marks a request that must not carry X-Organization-ID: the user's own list
34
+ * of organisations is how a stale organisation id is found out, so it cannot
35
+ * depend on one. An app's interceptor honours it by passing the request's
36
+ * config to getRequestOrganizationId(config).
37
+ *
38
+ * axiosInstance.get("/organizations", withoutOrganization({ params }))
39
+ */
40
+ export declare const WITHOUT_ORGANIZATION_FLAG = "erpWithoutOrganization";
41
+ export declare function withoutOrganization<T extends object = Record<never, never>>(config?: T): T & {
42
+ erpWithoutOrganization: true;
43
+ };
44
+ /**
45
+ * Marks a request for one particular organisation, whatever this tab has
46
+ * active. Used to retry the organisation list under each stored id when an
47
+ * API refuses it without one (see http/organization-fallback.ts).
48
+ */
49
+ export declare const FOR_ORGANIZATION_FIELD = "erpOrganizationId";
50
+ /**
51
+ * Every organisation id this browser has stored for the tab — the tab's
52
+ * own, localStorage, the cookie — distinct, in that order.
53
+ */
54
+ export declare function getStoredOrganizationIds(): string[];
55
+ /**
56
+ * The organisation a request from this tab belongs to: the tab's own, then
57
+ * localStorage, then the cookie. Use it for the X-Organization-ID header —
58
+ * and read it before any `await` in the interceptor, so the header names the
59
+ * organisation that was active when the request was made.
60
+ *
61
+ * Pass the request's config: a request marked withoutOrganization() gets
62
+ * null, so no header.
63
+ *
64
+ * localStorage before the cookie: setOrganizationId writes localStorage
65
+ * synchronously and then awaits a server action for the cookie, so during
66
+ * that round trip the cookie still names the previous organisation.
67
+ *
68
+ * Once the user's organisations are known (setKnownOrganizationIds), the
69
+ * first of those that is one of them wins: 0.1.60 let a deleted
70
+ * organisation's id left in localStorage beat a valid cookie, and every
71
+ * request — the organisation list included — was refused. If none is a
72
+ * member the first is still sent, as before: the list may simply be behind
73
+ * (an organisation created a moment ago).
74
+ */
75
+ export declare function getRequestOrganizationId(config?: unknown): string | null;
76
+ /**
77
+ * Forget the active organisation everywhere this browser keeps it: the tab,
78
+ * localStorage and the `erp.organizationId` cookie. Call on sign-out, so the
79
+ * next person to sign in on this browser does not start in the previous
80
+ * person's organisation.
81
+ */
82
+ export declare function clearStoredOrganization(): void;
@@ -0,0 +1,25 @@
1
+ import { type QueryKey } from "@tanstack/react-query";
2
+ /**
3
+ * Every query is cached per organisation.
4
+ *
5
+ * Requests are scoped by the X-Organization-ID header, so almost no query key
6
+ * names the organisation — ["purchase-requests", "detail", id] is the same key
7
+ * in every company. A key that is the same in two organisations is one cache
8
+ * entry: what was fetched in one was a fresh cache hit in the other, and a
9
+ * page opened under another organisation's URL rendered the first one's
10
+ * document until something refetched it.
11
+ *
12
+ * The hash — the identity of a cache entry — therefore carries the
13
+ * organisation requests are sent with right now (getQueryClientConfig passes
14
+ * getRequestOrganizationId()). The key itself is untouched,
15
+ * so partial-key invalidation and every existing key keep working; a switch
16
+ * simply lands on a different set of entries.
17
+ */
18
+ export declare function organizationScopedQueryHash(queryKey: QueryKey, organizationId: string | null): string;
19
+ /**
20
+ * Retry a failed query at most twice — unless the API answered with a 4xx.
21
+ * A 404 for a document that is not in this organisation will not appear on a
22
+ * second ask; retrying held the page on its skeleton for three seconds before
23
+ * it could say "not found". 408 and 429 are transient and still retried.
24
+ */
25
+ export declare function shouldRetryQuery(failureCount: number, error: unknown): boolean;
@@ -1,7 +1,9 @@
1
1
  import { QueryClientConfig } from "@tanstack/react-query";
2
2
  /**
3
3
  * Default React Query config used across ERP apps:
4
- * - 2 retries on queries / 1 on mutations, exponential backoff
4
+ * - cache entries scoped by organisation (organizationScopedQueryHash)
5
+ * - 2 retries on queries except 4xx answers / none on mutations,
6
+ * exponential backoff
5
7
  * - 5 min staleTime, refetchOnWindowFocus
6
8
  * - swallows "no org context" errors to silence noise on the
7
9
  * select-organization page
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * This package's version, attached to help messages so the back office can
3
3
  * tell which shell a report came from. Keep in step with package.json.
4
4
  */
5
- export declare const SHELL_VERSION = "0.1.59";
5
+ export declare const SHELL_VERSION = "0.1.61";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eintrek/erp-shell",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Shared frontend platform for ERP apps — HTTP client, providers, navigation, hooks.",