@vtex/faststore-plugin-buyer-portal 2.0.30 → 2.0.31

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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/features/contracts/components/AddContractsDrawer/AddContractsDrawer.tsx +12 -1
  3. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +19 -11
  4. package/src/features/contracts/components/ContractListItem/__tests__/contractListItemMenu.test.ts +32 -0
  5. package/src/features/contracts/components/ContractListItem/contractListItemMenu.ts +17 -0
  6. package/src/features/contracts/components/ContractSelectionList/ContractSelectionListSkeleton.tsx +35 -0
  7. package/src/features/contracts/components/ContractSwitchOverlay/ContractSwitchOverlay.tsx +9 -1
  8. package/src/features/contracts/components/index.ts +2 -0
  9. package/src/features/contracts/hooks/useSwitchContract.ts +6 -4
  10. package/src/features/contracts/layouts/ContractInformationLayout/ContractInformationLayout.tsx +9 -12
  11. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +90 -41
  12. package/src/features/contracts/layouts/ContractListingLayout/__tests__/utils.test.ts +40 -1
  13. package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +10 -2
  14. package/src/features/contracts/layouts/ContractListingLayout/utils.ts +25 -0
  15. package/src/features/contracts/utils/__tests__/clearPersistedSessionState.test.ts +32 -0
  16. package/src/features/contracts/utils/__tests__/resolveSwitchRedirect.test.ts +21 -0
  17. package/src/features/contracts/utils/clearPersistedSessionState.ts +43 -5
  18. package/src/features/contracts/utils/resolveSwitchRedirect.ts +15 -0
  19. package/src/features/org-units/components/OrgUnitBreadcrumb/OrgUnitBreadcrumb.tsx +21 -31
  20. package/src/features/org-units/components/OrgUnitBreadcrumb/__tests__/buildBreadcrumbItems.test.ts +64 -0
  21. package/src/features/org-units/components/OrgUnitBreadcrumb/buildBreadcrumbItems.ts +36 -0
  22. package/src/features/org-units/components/OrgUnitDetailsNavbar/OrgUnitDetailsNavbar.tsx +67 -27
  23. package/src/features/org-units/components/OrgUnitDetailsNavbar/org-unit-details-navbar.scss +22 -1
  24. package/src/features/org-units/components/OrgUnitHeaderNav/OrgUnitHeaderNav.tsx +112 -0
  25. package/src/features/org-units/components/OrgUnitHeaderNav/__tests__/getOrgUnitHeaderNavItems.test.ts +83 -0
  26. package/src/features/org-units/components/OrgUnitHeaderNav/getOrgUnitHeaderNavItems.ts +36 -0
  27. package/src/features/org-units/components/OrgUnitHeaderNav/org-unit-header-nav.scss +129 -0
  28. package/src/features/org-units/components/index.ts +4 -0
  29. package/src/features/org-units/layouts/OrgUnitDetailsLayout/org-units-details.scss +1 -0
  30. package/src/features/org-units/types/OrgUnitBreadcrumbTypes.ts +8 -0
  31. package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +8 -31
  32. package/src/features/shared/hooks/index.ts +1 -0
  33. package/src/features/shared/hooks/useRefreshPageData.ts +35 -0
  34. package/src/features/shared/layouts/BaseTabsLayout/about-drawer/__tests__/aboutQueryParam.test.ts +23 -0
  35. package/src/features/shared/layouts/BaseTabsLayout/about-drawer/aboutQueryParam.ts +13 -0
  36. package/src/features/shared/layouts/ContractTabsLayout/ContractTabsLayout.tsx +2 -2
  37. package/src/features/shared/layouts/ErrorTabsLayout/ErrorTabsLayout.tsx +6 -1
  38. package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +35 -2
  39. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +33 -12
  40. package/src/features/shared/layouts/SumaPageLayout/SumaPageLayout.tsx +4 -2
  41. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +26 -17
  42. package/src/features/shared/layouts/SumaPageLayout/__tests__/resolveOrgLinkContractId.test.ts +16 -0
  43. package/src/features/shared/layouts/SumaPageLayout/__tests__/sumaSidebarConfig.test.ts +27 -0
  44. package/src/features/shared/layouts/SumaPageLayout/resolveOrgLinkContractId.ts +13 -0
  45. package/src/features/shared/layouts/SumaPageLayout/sumaSidebarConfig.ts +37 -0
  46. package/src/features/shared/utils/__tests__/getContractSettingsLinks.test.ts +18 -0
  47. package/src/features/shared/utils/__tests__/getSumaListingSidebarLinks.test.ts +43 -0
  48. package/src/features/shared/utils/__tests__/routeLayoutMapping.test.ts +34 -0
  49. package/src/features/shared/utils/constants.ts +1 -1
  50. package/src/features/shared/utils/getContractSettingsLinks.ts +6 -1
  51. package/src/features/shared/utils/routeLayoutMapping.ts +9 -1
  52. package/src/pages/contract-information.tsx +9 -1
  53. package/src/pages/contracts.tsx +5 -0
  54. package/src/pages/home.tsx +25 -19
  55. package/src/pages/org-unit-details.tsx +10 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.30",
3
+ "version": "2.0.31",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import { useUI } from "@faststore/ui";
4
4
 
@@ -15,6 +15,7 @@ import {
15
15
  buildRangeSet,
16
16
  buildToggleSet,
17
17
  } from "../../hooks/contractSelectionHelpers";
18
+ import { pluralSuffixArgs } from "../../layouts/ContractListingLayout/utils";
18
19
 
19
20
  export type AddContractsDrawerProps = Omit<BasicDrawerProps, "children"> & {
20
21
  orgUnitId: string;
@@ -43,10 +44,19 @@ export const AddContractsDrawer = ({
43
44
  fetchAvailableContracts,
44
45
  } = useListAvailableContracts(orgUnitId);
45
46
 
47
+ const pendingAddCountRef = useRef(0);
48
+
46
49
  const { addContracts, isAddContractsLoading } = useAddContracts({
47
50
  onSuccess: () => {
48
51
  onSuccess();
49
52
  drawerProps.onDismiss?.();
53
+ pushToast({
54
+ message: t(
55
+ "contracts.toasts.addSuccess",
56
+ pluralSuffixArgs(pendingAddCountRef.current)
57
+ ),
58
+ status: "INFO",
59
+ });
50
60
  },
51
61
  onError: () => {
52
62
  drawerProps.onDismiss?.();
@@ -129,6 +139,7 @@ export const AddContractsDrawer = ({
129
139
  };
130
140
 
131
141
  const handleConfirm = () => {
142
+ pendingAddCountRef.current = selectedIds.size;
132
143
  addContracts({
133
144
  orgUnitId,
134
145
  ids: Array.from(selectedIds),
@@ -6,6 +6,8 @@ import { useLocalization } from "../../../shared/localization/LocalizationContex
6
6
  import { useSwitchContract } from "../../hooks";
7
7
  import { ContractSwitchOverlay } from "../ContractSwitchOverlay/ContractSwitchOverlay";
8
8
 
9
+ import { getContractMenuState } from "./contractListItemMenu";
10
+
9
11
  import type { ContractData } from "../../types";
10
12
 
11
13
  export type ContractListItemProps = {
@@ -30,6 +32,10 @@ export const ContractListItem = ({
30
32
  const { pushToast } = useUI();
31
33
  const { switchContract, loading } = useSwitchContract();
32
34
  const isRemovable = contract.isAttached !== false;
35
+ const menuState = getContractMenuState({
36
+ isDefault: contract.isDefault,
37
+ isRemovable,
38
+ });
33
39
 
34
40
  const handleCheckboxClick = (event: React.MouseEvent) => {
35
41
  event.stopPropagation();
@@ -97,18 +103,20 @@ export const ContractListItem = ({
97
103
  <Dropdown>
98
104
  <BasicDropdownMenu.Trigger />
99
105
  <BasicDropdownMenu>
100
- {contract.isDefault || !isRemovable ? (
101
- <DropdownItem disabled>
102
- <Icon name="Star" width={16} height={16} />
103
- {t("contracts.actions.setAsDefault")}
104
- </DropdownItem>
105
- ) : (
106
- <DropdownItem onClick={onSetDefault}>
107
- <Icon name="StarOutline" width={16} height={16} />
108
- {t("contracts.actions.setAsDefault")}
109
- </DropdownItem>
106
+ {menuState.showSetDefault && (
107
+ <>
108
+ <DropdownItem
109
+ onClick={
110
+ menuState.setDefaultEnabled ? onSetDefault : undefined
111
+ }
112
+ disabled={!menuState.setDefaultEnabled}
113
+ >
114
+ <Icon name="StarOutline" width={16} height={16} />
115
+ {t("contracts.actions.setAsDefault")}
116
+ </DropdownItem>
117
+ <BasicDropdownMenu.Separator />
118
+ </>
110
119
  )}
111
- <BasicDropdownMenu.Separator />
112
120
  <DropdownItem
113
121
  data-fs-bp-dropdown-menu-item-mode="danger"
114
122
  disabled={!isRemovable}
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getContractMenuState } from "../contractListItemMenu";
4
+
5
+ describe("getContractMenuState", () => {
6
+ it("hides 'Set as default' when the contract is already the default", () => {
7
+ expect(
8
+ getContractMenuState({ isDefault: true, isRemovable: true })
9
+ ).toEqual({
10
+ showSetDefault: false,
11
+ setDefaultEnabled: false,
12
+ });
13
+ });
14
+
15
+ it("shows and enables 'Set as default' for a removable non-default contract", () => {
16
+ expect(
17
+ getContractMenuState({ isDefault: false, isRemovable: true })
18
+ ).toEqual({
19
+ showSetDefault: true,
20
+ setDefaultEnabled: true,
21
+ });
22
+ });
23
+
24
+ it("shows but disables 'Set as default' for a legacy (non-attached) contract", () => {
25
+ expect(
26
+ getContractMenuState({ isDefault: undefined, isRemovable: false })
27
+ ).toEqual({
28
+ showSetDefault: true,
29
+ setDefaultEnabled: false,
30
+ });
31
+ });
32
+ });
@@ -0,0 +1,17 @@
1
+ export type ContractMenuState = {
2
+ /** The default contract never offers "Set as default" (AS-8). */
3
+ showSetDefault: boolean;
4
+ /** Legacy non-attached contracts keep the item visible but disabled. */
5
+ setDefaultEnabled: boolean;
6
+ };
7
+
8
+ export const getContractMenuState = ({
9
+ isDefault,
10
+ isRemovable,
11
+ }: {
12
+ isDefault?: boolean;
13
+ isRemovable: boolean;
14
+ }): ContractMenuState => ({
15
+ showSetDefault: !isDefault,
16
+ setDefaultEnabled: !isDefault && isRemovable,
17
+ });
@@ -0,0 +1,35 @@
1
+ import { Skeleton } from "@faststore/ui";
2
+
3
+ export type ContractSelectionListSkeletonProps = {
4
+ /** Number of placeholder rows; defaults to a typical page. */
5
+ rows?: number;
6
+ };
7
+
8
+ /**
9
+ * Placeholder rows shaped like `ContractListItem`, shown while the listing
10
+ * loads (route transition) or refreshes after adding/removing contracts.
11
+ */
12
+ export const ContractSelectionListSkeleton = ({
13
+ rows = 4,
14
+ }: ContractSelectionListSkeletonProps) => (
15
+ <div
16
+ data-fs-bp-contract-selection-list
17
+ data-fs-bp-contract-selection-list-skeleton
18
+ aria-busy="true"
19
+ aria-live="polite"
20
+ >
21
+ <div data-fs-bp-contract-selection-list-rows>
22
+ {Array.from({ length: Math.max(rows, 1) }, (_, index) => (
23
+ <div
24
+ data-fs-bp-contract-list-item
25
+ data-fs-bp-contract-list-item-skeleton
26
+ key={`contract-skeleton-${index}`}
27
+ >
28
+ <Skeleton size={{ width: "1.25rem", height: "1.25rem" }} />
29
+ <Skeleton size={{ width: "2rem", height: "2rem" }} />
30
+ <Skeleton size={{ width: "14rem", height: "1.25rem" }} />
31
+ </div>
32
+ ))}
33
+ </div>
34
+ </div>
35
+ );
@@ -1,3 +1,4 @@
1
+ import { useBuyerPortal } from "../../../shared/hooks";
1
2
  import { ContractTabsLayout } from "../../../shared/layouts/ContractTabsLayout/ContractTabsLayout";
2
3
  import { LoadingTabsLayoutContent } from "../../../shared/layouts/LoadingTabsLayout/LoadingTabsLayout";
3
4
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
@@ -21,6 +22,7 @@ export const ContractSwitchOverlay = ({
21
22
  contractId,
22
23
  }: ContractSwitchOverlayProps) => {
23
24
  const { t } = useLocalization();
25
+ const { featureFlags } = useBuyerPortal();
24
26
 
25
27
  return (
26
28
  <div data-fs-bp-contract-switch-overlay role="alert" aria-busy="true">
@@ -32,7 +34,13 @@ export const ContractSwitchOverlay = ({
32
34
  pageName={t("layouts.tabs.contract")}
33
35
  loading
34
36
  >
35
- <LoadingTabsLayoutContent title={t("layouts.titles.profile")} />
37
+ <LoadingTabsLayoutContent
38
+ title={t(
39
+ featureFlags?.suma
40
+ ? "layouts.navigation.basicInformation"
41
+ : "layouts.titles.profile"
42
+ )}
43
+ />
36
44
  </ContractTabsLayout>
37
45
  </div>
38
46
  );
@@ -3,6 +3,8 @@ export { ContractListItem } from "./ContractListItem/ContractListItem";
3
3
  export type { ContractListItemProps } from "./ContractListItem/ContractListItem";
4
4
  export { ContractSelectionList } from "./ContractSelectionList/ContractSelectionList";
5
5
  export type { ContractSelectionListProps } from "./ContractSelectionList/ContractSelectionList";
6
+ export { ContractSelectionListSkeleton } from "./ContractSelectionList/ContractSelectionListSkeleton";
7
+ export type { ContractSelectionListSkeletonProps } from "./ContractSelectionList/ContractSelectionListSkeleton";
6
8
  export { ConfirmRemoveDrawer } from "./ConfirmRemoveDrawer/ConfirmRemoveDrawer";
7
9
  export type { ConfirmRemoveDrawerProps } from "./ConfirmRemoveDrawer/ConfirmRemoveDrawer";
8
10
  export { ErrorRemoveDrawer } from "./ErrorRemoveDrawer/ErrorRemoveDrawer";
@@ -1,16 +1,18 @@
1
1
  import { useState } from "react";
2
2
 
3
- import { buyerPortalRoutes } from "../../shared/utils/buyerPortalRoutes";
4
3
  import {
5
4
  ContractSwitchError,
6
5
  changeContractToken,
7
6
  } from "../utils/changeContractToken";
8
7
  import { clearPersistedSessionState } from "../utils/clearPersistedSessionState";
8
+ import { resolveSwitchRedirect } from "../utils/resolveSwitchRedirect";
9
9
 
10
10
  import { useContractSwitchChannel } from "./useContractSwitchChannel";
11
11
 
12
12
  export type SwitchContractParams = {
13
13
  orgUnitId: string;
14
+ /** Where to land after the switch. Defaults to the contract's Profile page. */
15
+ redirectTo?: string;
14
16
  };
15
17
 
16
18
  export type UseSwitchContractReturn = {
@@ -28,7 +30,7 @@ export type UseSwitchContractReturn = {
28
30
  * 1. POST switch-properties with the target contract id and apply the returned auth cookie.
29
31
  * 2. Clear persisted host session state.
30
32
  * 3. Broadcast to other tabs so they reload under the new context.
31
- * 4. Full-navigate to the contract's profile page so SSR re-runs with the new cookie.
33
+ * 4. Full-navigate to `redirectTo` (default: the contract's profile page) so SSR re-runs with the new cookie.
32
34
  *
33
35
  * On failure the previous contract stays active and `switchContract` resolves to `false`.
34
36
  */
@@ -39,7 +41,7 @@ export function useSwitchContract(): UseSwitchContractReturn {
39
41
 
40
42
  const switchContract = async (
41
43
  contractId: string,
42
- { orgUnitId }: SwitchContractParams
44
+ { orgUnitId, redirectTo }: SwitchContractParams
43
45
  ): Promise<boolean> => {
44
46
  setLoading(true);
45
47
  setError(null);
@@ -52,7 +54,7 @@ export function useSwitchContract(): UseSwitchContractReturn {
52
54
 
53
55
  if (typeof window !== "undefined") {
54
56
  window.location.assign(
55
- buyerPortalRoutes.profileDetails({ orgUnitId, contractId })
57
+ resolveSwitchRedirect({ orgUnitId, contractId, redirectTo })
56
58
  );
57
59
  }
58
60
 
@@ -21,6 +21,7 @@ import { ConfirmRemoveDrawer } from "../../components/ConfirmRemoveDrawer/Confir
21
21
  import { ErrorRemoveDrawer } from "../../components/ErrorRemoveDrawer/ErrorRemoveDrawer";
22
22
  import { useContractSwitchChannel, useSetDefaultContract } from "../../hooks";
23
23
  import { removeContractService } from "../../services";
24
+ import { pluralSuffixArgs } from "../ContractListingLayout/utils";
24
25
 
25
26
  import type { OrgUnitBasicData } from "../../../org-units/types";
26
27
  import type { FeatureFlags } from "../../../shared/components/BuyerPortalProvider/BuyerPortalProvider";
@@ -125,6 +126,7 @@ export const ContractInformationLayout = ({
125
126
  const router = useRouter();
126
127
  const { broadcast } = useContractSwitchChannel();
127
128
  const { featureFlags } = useBuyerPortal();
129
+ const { pushToast } = useUI();
128
130
 
129
131
  const contract = contracts.find((c) => c.id === contractId);
130
132
  const isSingleContract = contracts.length <= 1;
@@ -144,8 +146,12 @@ export const ContractInformationLayout = ({
144
146
 
145
147
  const handleRemoveSuccess = useCallback(() => {
146
148
  broadcast({ type: "contract-removed", contractId });
149
+ pushToast({
150
+ message: t("contracts.toasts.removeSuccess", pluralSuffixArgs(1)),
151
+ status: "INFO",
152
+ });
147
153
  router.push(buyerPortalRoutes.contracts({ orgUnitId: orgUnit.id }));
148
- }, [broadcast, contractId, orgUnit.id, router]);
154
+ }, [broadcast, contractId, orgUnit.id, router, pushToast, t]);
149
155
 
150
156
  const handleRemoveError = useCallback(() => {
151
157
  confirmDrawerProps.close();
@@ -173,7 +179,6 @@ export const ContractInformationLayout = ({
173
179
  openConfirmDrawer();
174
180
  };
175
181
 
176
- const { pushToast } = useUI();
177
182
  const { setDefaultContract, isSetDefaultContractLoading } =
178
183
  useSetDefaultContract({
179
184
  onSuccess: () => {
@@ -224,7 +229,7 @@ export const ContractInformationLayout = ({
224
229
  <Link
225
230
  href={contractsUrl}
226
231
  data-fs-bp-contract-information-back-link
227
- aria-label="Back to contracts"
232
+ aria-label={t("layouts.navigation.contracts")}
228
233
  >
229
234
  <Icon name="ArrowPointLeft" width={16} height={16} />
230
235
  {t("layouts.navigation.contracts")}
@@ -239,15 +244,7 @@ export const ContractInformationLayout = ({
239
244
  <Dropdown>
240
245
  <BasicDropdownMenu.Trigger />
241
246
  <BasicDropdownMenu>
242
- {contract?.isDefault ? (
243
- <DropdownItem
244
- data-fs-bp-contract-information-more-menu-set-default
245
- disabled
246
- >
247
- <Icon name="Star" width={16} height={16} />
248
- {t("contracts.actions.setAsDefault")}
249
- </DropdownItem>
250
- ) : (
247
+ {contract && !contract.isDefault && (
251
248
  <DropdownItem
252
249
  data-fs-bp-contract-information-more-menu-set-default
253
250
  onClick={handleSetDefault}
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  useDrawerProps,
19
19
  useBuyerPortal,
20
+ useRefreshPageData,
20
21
  useReloadOnBfcacheRestore,
21
22
  } from "../../../shared/hooks";
22
23
  import { GlobalLayout } from "../../../shared/layouts";
@@ -29,6 +30,7 @@ import { ConfirmRemoveDrawer } from "../../components/ConfirmRemoveDrawer/Confir
29
30
  import { ContractSelectAllRow } from "../../components/ContractSelectAllRow";
30
31
  import { ContractSelectionBar } from "../../components/ContractSelectionBar";
31
32
  import { ContractSelectionList } from "../../components/ContractSelectionList/ContractSelectionList";
33
+ import { ContractSelectionListSkeleton } from "../../components/ContractSelectionList/ContractSelectionListSkeleton";
32
34
  import { ContractsListHeader } from "../../components/ContractsListHeader";
33
35
  import { UnableToRemoveContractsDrawer } from "../../components/UnableToRemoveContractsDrawer/UnableToRemoveContractsDrawer";
34
36
  import {
@@ -41,7 +43,14 @@ import {
41
43
  } from "../../hooks";
42
44
 
43
45
  import { buildContractSettingsNavItems } from "./ContractSettingsNav";
44
- import { getDefaultContractId, resolveViewedContractId } from "./utils";
46
+ import {
47
+ REMOVED_QUERY_PARAM,
48
+ buildRemovedRedirectUrl,
49
+ getDefaultContractId,
50
+ parseRemovedCount,
51
+ pluralSuffixArgs,
52
+ resolveViewedContractId,
53
+ } from "./utils";
45
54
 
46
55
  import type { OrgUnitBasicData } from "../../../org-units/types";
47
56
  import type { UserData } from "../../../users/types";
@@ -65,6 +74,10 @@ export const ContractListingLayout = ({
65
74
  const router = useRouter();
66
75
  const { pushToast } = useUI();
67
76
  const { subscribe } = useContractSwitchChannel();
77
+ // Re-runs the loader after add/remove/set-default; `isRefreshing` drives the
78
+ // inline skeleton (a same-URL replace shows no route-change skeleton).
79
+ const { refresh, isRefreshing } = useRefreshPageData();
80
+ const isBusy = loading || isRefreshing;
68
81
 
69
82
  const queryContractId = Array.isArray(router.query.contractId)
70
83
  ? router.query.contractId[0]
@@ -78,10 +91,14 @@ export const ContractListingLayout = ({
78
91
  listAccountingFieldData,
79
92
  listAccountingFieldLoading,
80
93
  listAccountingFieldRefresh,
81
- } = useListAccountingFields({
82
- orgUnitId: orgUnit.id,
83
- contractId: defaultContractId,
84
- });
94
+ } = useListAccountingFields(
95
+ {
96
+ orgUnitId: orgUnit.id,
97
+ contractId: defaultContractId,
98
+ },
99
+ // No contract to scope by (route-transition skeleton) → nothing to fetch.
100
+ { lazy: !defaultContractId }
101
+ );
85
102
 
86
103
  const {
87
104
  open: openAccountingFieldDrawer,
@@ -130,27 +147,31 @@ export const ContractListingLayout = ({
130
147
  const fallbackContractId = getDefaultContractId(remainingContracts);
131
148
 
132
149
  if (fallbackContractId) {
133
- switchContract(fallbackContractId, { orgUnitId: orgUnit.id }).then(
134
- (switched) => {
135
- if (!switched) {
136
- pushToast({
137
- message: t("contracts.toasts.refreshError"),
138
- status: "ERROR",
139
- });
140
- router.replace(router.asPath);
141
- }
150
+ switchContract(fallbackContractId, {
151
+ orgUnitId: orgUnit.id,
152
+ redirectTo: buildRemovedRedirectUrl(
153
+ orgUnit.id,
154
+ pendingRemoveIds.size
155
+ ),
156
+ }).then((switched) => {
157
+ if (!switched) {
158
+ pushToast({
159
+ message: t("contracts.toasts.refreshError"),
160
+ status: "ERROR",
161
+ });
162
+ void refresh();
142
163
  }
143
- );
164
+ });
144
165
  return;
145
166
  }
146
167
  }
147
168
 
148
- router.replace(router.asPath);
169
+ void refresh();
149
170
  pushToast({
150
- message: t("contracts.toasts.removeSuccess", [
151
- pendingRemoveIds.size,
152
- pendingRemoveIds.size === 1 ? "" : "s",
153
- ]),
171
+ message: t(
172
+ "contracts.toasts.removeSuccess",
173
+ pluralSuffixArgs(pendingRemoveIds.size)
174
+ ),
154
175
  status: "INFO",
155
176
  });
156
177
  },
@@ -199,7 +220,7 @@ export const ContractListingLayout = ({
199
220
 
200
221
  const { setDefaultContract } = useSetDefaultContract({
201
222
  onSuccess: () => {
202
- router.replace(router.asPath);
223
+ void refresh();
203
224
  pushToast({
204
225
  message: t("contracts.toasts.setDefaultSuccess"),
205
226
  status: "INFO",
@@ -213,7 +234,7 @@ export const ContractListingLayout = ({
213
234
  } catch {
214
235
  // message is not JSON
215
236
  }
216
- void router.replace(router.asPath).catch(() => {});
237
+ void refresh();
217
238
  pushToast({
218
239
  message: apiMessage ?? t("contracts.toasts.setDefaultError"),
219
240
  status: "ERROR",
@@ -249,6 +270,24 @@ export const ContractListingLayout = ({
249
270
  }
250
271
  }, [router.query.error]);
251
272
 
273
+ useEffect(() => {
274
+ const removedCount = parseRemovedCount(router.query[REMOVED_QUERY_PARAM]);
275
+ if (removedCount === null) return;
276
+
277
+ pushToast({
278
+ message: t(
279
+ "contracts.toasts.removeSuccess",
280
+ pluralSuffixArgs(removedCount)
281
+ ),
282
+ status: "INFO",
283
+ });
284
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
285
+ const { [REMOVED_QUERY_PARAM]: _removed, ...rest } = router.query;
286
+ router.replace({ pathname: router.pathname, query: rest }, undefined, {
287
+ shallow: true,
288
+ });
289
+ }, [router.query[REMOVED_QUERY_PARAM]]);
290
+
252
291
  const {
253
292
  selectedIds,
254
293
  handleModifierClick,
@@ -328,22 +367,22 @@ export const ContractListingLayout = ({
328
367
  email: user?.email ?? "",
329
368
  }}
330
369
  loading={loading}
331
- sidebarVariant="listing"
370
+ sidebarVariant="unit"
332
371
  />
333
372
  <div data-fs-bp-contract-listing-body>
334
373
  <SumaSidebar
335
374
  orgUnitId={orgUnit.id}
336
375
  contractId={defaultContractId}
337
- variant="listing"
376
+ variant="unit"
338
377
  />
339
378
 
340
- <section data-fs-bp-contract-listing-section>
379
+ <section data-fs-bp-contract-listing-section aria-busy={isBusy}>
341
380
  <HeaderInside
342
- title={t("contracts.titles.contracts")}
343
- info={headerInfo}
381
+ title={loading ? undefined : t("contracts.titles.contracts")}
382
+ info={loading ? undefined : headerInfo}
344
383
  data-fs-bp-contract-listing-header
345
384
  >
346
- {featureFlags?.suma && (
385
+ {featureFlags?.suma && !loading && (
347
386
  <HeaderInside.Button
348
387
  onClick={openAddContractsDrawer}
349
388
  aria-label="Add contracts"
@@ -351,7 +390,11 @@ export const ContractListingLayout = ({
351
390
  )}
352
391
  </HeaderInside>
353
392
 
354
- {isSingleContract ? (
393
+ {loading ? (
394
+ // Route-transition skeleton (LoadingTabsLayout): the destination
395
+ // is the multi-contract listing, so mirror its rows.
396
+ <ContractSelectionListSkeleton />
397
+ ) : isSingleContract ? (
355
398
  <BasicCard
356
399
  data-fs-bp-contracts-settings-card
357
400
  footerMessage={t("orgUnitDetails.links.manageContractSettings")}
@@ -362,8 +405,8 @@ export const ContractListingLayout = ({
362
405
  enableFooter
363
406
  >
364
407
  <LetterHighlight letter={displayName} />
365
- <VerticalNav.Menu title={displayName} loading={loading}>
366
- {loading ? (
408
+ <VerticalNav.Menu title={displayName} loading={isBusy}>
409
+ {isBusy ? (
367
410
  [...new Array(8)].map((_, index) => (
368
411
  <li style={{ padding: "8px 0" }} key={`skeleton-${index}`}>
369
412
  <Skeleton
@@ -408,15 +451,21 @@ export const ContractListingLayout = ({
408
451
  selectedCount={selectedInFiltered}
409
452
  onToggle={handleToggleSelectAll}
410
453
  />
411
- <ContractSelectionList
412
- contracts={pageItems}
413
- orgUnitId={orgUnit.id}
414
- selectedIds={selectedIds}
415
- onKeyDown={handleKeyDown}
416
- onSelect={handleSelect}
417
- onItemRemove={handleItemRemove}
418
- onSetDefault={handleSetDefault}
419
- />
454
+ {isRefreshing ? (
455
+ <ContractSelectionListSkeleton
456
+ rows={Math.max(pageItems.length, 3)}
457
+ />
458
+ ) : (
459
+ <ContractSelectionList
460
+ contracts={pageItems}
461
+ orgUnitId={orgUnit.id}
462
+ selectedIds={selectedIds}
463
+ onKeyDown={handleKeyDown}
464
+ onSelect={handleSelect}
465
+ onItemRemove={handleItemRemove}
466
+ onSetDefault={handleSetDefault}
467
+ />
468
+ )}
420
469
  <ContractSelectionBar
421
470
  selectedCount={selectedIds.size}
422
471
  onClear={clearSelection}
@@ -443,7 +492,7 @@ export const ContractListingLayout = ({
443
492
  orgUnitId={orgUnit.id}
444
493
  orgUnitName={orgUnit.name}
445
494
  existingContractIds={contracts.map((c) => c.id)}
446
- onSuccess={() => router.replace(router.asPath)}
495
+ onSuccess={() => void refresh()}
447
496
  {...addContractsDrawer}
448
497
  />
449
498
  )}
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { getDefaultContractId, resolveViewedContractId } from "../utils";
3
+ import {
4
+ buildRemovedRedirectUrl,
5
+ getDefaultContractId,
6
+ parseRemovedCount,
7
+ pluralSuffixArgs,
8
+ resolveViewedContractId,
9
+ } from "../utils";
4
10
 
5
11
  import type { ContractData } from "../../../types";
6
12
 
@@ -38,3 +44,36 @@ describe("resolveViewedContractId", () => {
38
44
  expect(resolveViewedContractId(contracts, undefined)).toBe("contract-b");
39
45
  });
40
46
  });
47
+
48
+ describe("pluralSuffixArgs", () => {
49
+ it("returns no suffix for one item", () => {
50
+ expect(pluralSuffixArgs(1)).toEqual([1, ""]);
51
+ });
52
+
53
+ it("returns an 's' suffix for zero or many items", () => {
54
+ expect(pluralSuffixArgs(0)).toEqual([0, "s"]);
55
+ expect(pluralSuffixArgs(3)).toEqual([3, "s"]);
56
+ });
57
+ });
58
+
59
+ describe("buildRemovedRedirectUrl", () => {
60
+ it("points at the unit's listing with the removed count", () => {
61
+ expect(buildRemovedRedirectUrl("unit-1", 2)).toBe(
62
+ "/pvt/organization-account/contracts/unit-1?removed=2"
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("parseRemovedCount", () => {
68
+ it("parses a positive integer", () => {
69
+ expect(parseRemovedCount("3")).toBe(3);
70
+ expect(parseRemovedCount(["2", "9"])).toBe(2);
71
+ });
72
+
73
+ it("rejects missing, zero, negative or non-numeric values", () => {
74
+ expect(parseRemovedCount(undefined)).toBeNull();
75
+ expect(parseRemovedCount("0")).toBeNull();
76
+ expect(parseRemovedCount("-1")).toBeNull();
77
+ expect(parseRemovedCount("abc")).toBeNull();
78
+ });
79
+ });
@@ -101,7 +101,7 @@
101
101
  }
102
102
 
103
103
  // Match old [data-fs-bp-sidebar-menu] typography and colors
104
- &[data-fs-bp-suma-sidebar-variant="full"] {
104
+ &[data-fs-bp-suma-sidebar-variant="contract"] {
105
105
  [data-fs-vertical-nav-menu] {
106
106
  margin-bottom: var(--fs-bp-padding-5);
107
107
 
@@ -137,7 +137,7 @@
137
137
  }
138
138
  }
139
139
 
140
- &[data-fs-bp-suma-sidebar-variant="listing"] {
140
+ &[data-fs-bp-suma-sidebar-variant="unit"] {
141
141
  [data-fs-vertical-nav-menu-list] {
142
142
  margin-top: var(--fs-bp-margin-0);
143
143
  @include text-style("emphasis");
@@ -420,6 +420,14 @@
420
420
  background-color: #f5f5f5;
421
421
  }
422
422
 
423
+ &[data-fs-bp-contract-list-item-skeleton] {
424
+ cursor: default;
425
+
426
+ &:hover {
427
+ background-color: transparent;
428
+ }
429
+ }
430
+
423
431
  &[data-fs-bp-contract-list-item-selected] {
424
432
  background-color: #f0f7ff;
425
433