@xenide-io/the-old-ui-theme 0.9.13 → 0.9.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -46,6 +46,39 @@ describe("SuiteIntegrationPicker", () => {
46
46
  expect(screen.queryByText("Linear")).not.toBeInTheDocument();
47
47
  });
48
48
 
49
+ it("filters to bridge-capable apps when Shelly Bridge only is on", async () => {
50
+ const user = userEvent.setup();
51
+
52
+ render(
53
+ <SuiteIntegrationPicker
54
+ open
55
+ items={[
56
+ {
57
+ id: "bridged",
58
+ label: "Bridged app",
59
+ description: "Can bridge into Tides",
60
+ bridgeTargets: ["tides"],
61
+ },
62
+ {
63
+ id: "shelly-only",
64
+ label: "Shelly only app",
65
+ description: "AI only",
66
+ },
67
+ ]}
68
+ onClose={vi.fn()}
69
+ onConnect={vi.fn()}
70
+ />,
71
+ );
72
+
73
+ expect(screen.getByText("Bridged app")).toBeInTheDocument();
74
+ expect(screen.getByText("Shelly only app")).toBeInTheDocument();
75
+
76
+ await user.click(screen.getByLabelText("Shelly Bridge only"));
77
+
78
+ expect(screen.getByText("Bridged app")).toBeInTheDocument();
79
+ expect(screen.queryByText("Shelly only app")).not.toBeInTheDocument();
80
+ });
81
+
49
82
  it("shows auth tags and passes the selected method to the connector", async () => {
50
83
  const user = userEvent.setup();
51
84
  const onConnect = vi.fn();
@@ -59,6 +92,7 @@ describe("SuiteIntegrationPicker", () => {
59
92
  label: "Mixed provider",
60
93
  description: "Supports multiple connection methods",
61
94
  categories: ["calendar", "email"],
95
+ bridgeTargets: ["turtletime", "tides"],
62
96
  authSchemes: ["OAUTH2", "API_KEY"],
63
97
  },
64
98
  ]}
@@ -69,8 +103,8 @@ describe("SuiteIntegrationPicker", () => {
69
103
 
70
104
  expect(screen.getByText("OAuth 2.0")).toBeInTheDocument();
71
105
  expect(screen.getByText("API key")).toBeInTheDocument();
72
- expect(screen.getByLabelText("Works with TurtleTime")).toBeInTheDocument();
73
- expect(screen.getByLabelText("Works with Tides")).toBeInTheDocument();
106
+ expect(screen.getByTitle("Bridge in TurtleTime")).toBeInTheDocument();
107
+ expect(screen.getByTitle("Bridge in Tides")).toBeInTheDocument();
74
108
 
75
109
  await user.click(screen.getByRole("button", { name: "Connect" }));
76
110
  await user.click(screen.getByRole("button", { name: /API key Select/ }));
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import {
13
+ Bridge3d,
13
14
  EvPlug as Plug,
14
15
  Filter,
15
16
  NavArrowDown,
@@ -18,8 +19,6 @@ import {
18
19
  Xmark,
19
20
  } from "iconoir-react";
20
21
  import { Button, Input, Modal } from "@/components/ui";
21
- import { SUITE_APPS, SUITE_APP_MAP, type SuiteAppSlug } from "@/suite/lib/apps";
22
- import { SuiteAppIcon } from "@/suite/icons";
23
22
 
24
23
  export interface SuiteIntegrationPickerItem {
25
24
  id: string;
@@ -27,6 +26,8 @@ export interface SuiteIntegrationPickerItem {
27
26
  description: string;
28
27
  icon?: ReactNode;
29
28
  categories?: string[];
29
+ /** Product apps this toolkit can actually bridge. Empty means Shelly AI only. */
30
+ bridgeTargets?: string[];
30
31
  authSchemes?: string[];
31
32
  connecting?: boolean;
32
33
  unavailableReason?: string;
@@ -63,6 +64,13 @@ export interface SuiteIntegrationPickerProps {
63
64
  category?: string;
64
65
  categories?: string[];
65
66
  onCategoryChange?: (value: string) => void;
67
+ /**
68
+ * When provided, the "Shelly Bridge only" filter is controlled by the caller
69
+ * (so it can refetch a bridge-filtered catalogue server-side). When omitted,
70
+ * the picker filters the loaded items locally.
71
+ */
72
+ bridgeOnly?: boolean;
73
+ onBridgeOnlyChange?: (value: boolean) => void;
66
74
  onClose: () => void;
67
75
  onConnect: (
68
76
  item: SuiteIntegrationPickerItem,
@@ -87,7 +95,7 @@ function IntegrationSkeletonCard() {
87
95
  aria-hidden="true"
88
96
  >
89
97
  <div className="flex items-start gap-3">
90
- <div className="h-10 w-10 shrink-0 rounded-lg bg-ph-muted" />
98
+ <div className="h-8 w-8 shrink-0 rounded-md bg-ph-muted" />
91
99
  <div className="min-w-0 flex-1 space-y-2 pt-1">
92
100
  <div className="h-4 w-3/5 rounded bg-ph-muted" />
93
101
  <div className="h-3 w-full rounded bg-ph-muted" />
@@ -128,46 +136,28 @@ function authSchemesFor(item: SuiteIntegrationPickerItem) {
128
136
  );
129
137
  }
130
138
 
131
- /**
132
- * Which ShellStack apps put a connected integration to work, per the deep
133
- * integration plan: calendar/mail feed TurtleTime time entries and Tides
134
- * tasks, code/chat/ticketing feed Tides tasks and Kraken runbooks, docs and
135
- * knowledge sources feed Kraken, CRM/finance feed the Portal. Unmapped
136
- * categories default to Kraken's authorised-source search.
137
- */
138
- const CATEGORY_APP_AFFINITY: Record<string, readonly SuiteAppSlug[]> = {
139
- calendar: ["turtletime", "tides"],
140
- email: ["turtletime", "tides"],
141
- code: ["tides", "kraken"],
142
- chat: ["tides", "kraken"],
143
- documents: ["kraken"],
144
- notes: ["kraken"],
145
- knowledge: ["kraken"],
146
- file_storage: ["kraken"],
147
- crm: ["shellstack", "tides"],
148
- finance: ["shellstack", "tides"],
149
- payment: ["shellstack", "tides"],
150
- monitoring: ["tides", "kraken"],
151
- design: ["tides", "kraken"],
152
- support: ["tides", "shellstack"],
153
- ticketing: ["tides", "kraken"],
139
+ const BRIDGE_MARK: Record<string, { src: string; name: string }> = {
140
+ turtletime: { src: "/turtletime-icon.svg", name: "TurtleTime" },
141
+ tides: { src: "/tides-icon.svg", name: "Tides" },
142
+ kraken: { src: "/kraken-icon.svg", name: "Kraken" },
154
143
  };
155
144
 
156
- const DEFAULT_APP_AFFINITY: readonly SuiteAppSlug[] = ["tides", "kraken"];
157
-
158
145
  export function suiteAppsForIntegration(
159
- categories: readonly string[] | undefined,
160
- ): SuiteAppSlug[] {
161
- const apps = new Set<SuiteAppSlug>();
162
- for (const category of categories ?? []) {
163
- for (const app of CATEGORY_APP_AFFINITY[category.trim().toLowerCase()] ??
164
- []) {
165
- apps.add(app);
166
- }
146
+ itemOrCategories:
147
+ | { bridgeTargets?: readonly string[] }
148
+ | readonly string[]
149
+ | undefined,
150
+ ): string[] {
151
+ if (
152
+ !itemOrCategories ||
153
+ Array.isArray(itemOrCategories) ||
154
+ !("bridgeTargets" in itemOrCategories)
155
+ ) {
156
+ return [];
167
157
  }
168
- return apps.size > 0
169
- ? SUITE_APPS.filter((app) => apps.has(app.slug)).map((app) => app.slug)
170
- : [...DEFAULT_APP_AFFINITY];
158
+ const explicit = itemOrCategories.bridgeTargets;
159
+ if (!Array.isArray(explicit)) return [];
160
+ return explicit.filter((app): app is string => app in BRIDGE_MARK);
171
161
  }
172
162
 
173
163
  export function SuiteIntegrationPicker({
@@ -184,6 +174,8 @@ export function SuiteIntegrationPicker({
184
174
  category: controlledCategory,
185
175
  categories: controlledCategories,
186
176
  onCategoryChange,
177
+ bridgeOnly: controlledBridgeOnly,
178
+ onBridgeOnlyChange,
187
179
  onClose,
188
180
  onConnect,
189
181
  onFetchAuthFields,
@@ -197,6 +189,7 @@ export function SuiteIntegrationPicker({
197
189
  const [categoryQuery, setCategoryQuery] = useState("");
198
190
  const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
199
191
  const [expanded, setExpanded] = useState<string | null>(null);
192
+ const [bridgeOnlyInternal, setBridgeOnlyInternal] = useState(false);
200
193
  const categoryMenuRef = useRef<HTMLDivElement>(null);
201
194
  const categoryTriggerRef = useRef<HTMLButtonElement>(null);
202
195
  const categorySearchRef = useRef<HTMLInputElement>(null);
@@ -207,6 +200,7 @@ export function SuiteIntegrationPicker({
207
200
  const scrollViewportRef = useRef<HTMLDivElement>(null);
208
201
  const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
209
202
  const loadMoreInFlightRef = useRef(false);
203
+ const sentinelVisibleRef = useRef(false);
210
204
  const authRequestRef = useRef(0);
211
205
  const [methodItem, setMethodItem] =
212
206
  useState<SuiteIntegrationPickerItem | null>(null);
@@ -234,11 +228,16 @@ export function SuiteIntegrationPicker({
234
228
  const categories = controlledCategories ?? itemCategories;
235
229
  const activeSearch = controlledSearch ?? query;
236
230
  const activeCategory = controlledCategory ?? category;
231
+ const bridgeOnly = controlledBridgeOnly ?? bridgeOnlyInternal;
232
+ const setBridgeOnly = (value: boolean) => {
233
+ setBridgeOnlyInternal(value);
234
+ onBridgeOnlyChange?.(value);
235
+ };
237
236
  const categoryLabel = (value: string) =>
238
237
  value
239
238
  .replace(/[-_]/g, " ")
240
239
  .replace(/\b\w/g, (letter) => letter.toUpperCase());
241
- const filtered =
240
+ const catalogItems =
242
241
  onSearchChange || onCategoryChange
243
242
  ? items
244
243
  : items.filter((item) => {
@@ -250,6 +249,10 @@ export function SuiteIntegrationPicker({
250
249
  (item.categories ?? []).includes(activeCategory))
251
250
  );
252
251
  });
252
+ const filtered =
253
+ bridgeOnly && controlledBridgeOnly === undefined
254
+ ? catalogItems.filter((item) => (item.bridgeTargets ?? []).length > 0)
255
+ : catalogItems;
253
256
  const visibleCategories = categories.filter((value) =>
254
257
  categoryLabel(value)
255
258
  .toLowerCase()
@@ -480,27 +483,37 @@ export function SuiteIntegrationPicker({
480
483
  useEffect(() => {
481
484
  const root = scrollViewportRef.current;
482
485
  const sentinel = loadMoreSentinelRef.current;
483
- if (
484
- !open ||
485
- !root ||
486
- !sentinel ||
487
- !hasMore ||
488
- !onEndReached ||
489
- loading ||
490
- catalogLoading ||
491
- loadingMore
492
- ) {
486
+ if (!open || !root || !sentinel) {
487
+ sentinelVisibleRef.current = false;
493
488
  return;
494
489
  }
495
490
 
496
491
  const observer = new IntersectionObserver(
497
492
  ([entry]) => {
498
- if (!entry.isIntersecting || loadMoreInFlightRef.current) return;
493
+ // Only auto-load on the rising edge of the sentinel becoming visible.
494
+ // Without this, shrinking the list (search / category / Shelly Bridge
495
+ // only) leaves the sentinel pinned in view and each re-armed observer
496
+ // fires again, chain-loading the whole catalog page after page.
497
+ if (!entry.isIntersecting) {
498
+ sentinelVisibleRef.current = false;
499
+ return;
500
+ }
501
+ if (sentinelVisibleRef.current) return;
502
+ sentinelVisibleRef.current = true;
503
+ if (
504
+ loadMoreInFlightRef.current ||
505
+ !hasMore ||
506
+ !onEndReached ||
507
+ loading ||
508
+ catalogLoading ||
509
+ loadingMore
510
+ ) {
511
+ return;
512
+ }
499
513
  loadMoreInFlightRef.current = true;
500
- observer.unobserve(entry.target);
501
514
  onEndReached();
502
515
  },
503
- { root, rootMargin: "0px 0px 80px", threshold: 0 },
516
+ { root, rootMargin: "0px 0px 120px", threshold: 0 },
504
517
  );
505
518
 
506
519
  observer.observe(sentinel);
@@ -524,6 +537,24 @@ export function SuiteIntegrationPicker({
524
537
  {connectionError}
525
538
  </p>
526
539
  ) : null}
540
+ <label
541
+ className="inline-flex w-fit cursor-pointer items-center gap-2 text-sm text-ph-ink"
542
+ data-test={`${dataTest}-bridge-only`}
543
+ >
544
+ <input
545
+ type="checkbox"
546
+ role="switch"
547
+ className="ph-toggle"
548
+ checked={bridgeOnly}
549
+ onChange={(event) => setBridgeOnly(event.target.checked)}
550
+ aria-label="Shelly Bridge only"
551
+ />
552
+ <Bridge3d
553
+ className="h-4 w-4 shrink-0 text-ph-subtle"
554
+ aria-hidden
555
+ />
556
+ <span>Shelly Bridge only</span>
557
+ </label>
527
558
  <div className="grid gap-4 md:grid-cols-2">
528
559
  <div className="relative">
529
560
  <Search
@@ -652,6 +683,8 @@ export function SuiteIntegrationPicker({
652
683
  const isExpanded = expanded === item.id;
653
684
  const longDescription =
654
685
  item.description.length > DESCRIPTION_LIMIT;
686
+ const bridgeApps = suiteAppsForIntegration(item);
687
+ const schemes = authSchemesFor(item);
655
688
  const text =
656
689
  isExpanded || !longDescription
657
690
  ? item.description
@@ -671,9 +704,9 @@ export function SuiteIntegrationPicker({
671
704
  aria-expanded={isExpanded}
672
705
  >
673
706
  <span className="flex items-start gap-3">
674
- <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink">
707
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-ph-muted text-ph-ink">
675
708
  {item.icon ?? (
676
- <Plug className="h-5 w-5" aria-hidden />
709
+ <Plug className="h-4 w-4" aria-hidden />
677
710
  )}
678
711
  </span>
679
712
  <span className="min-w-0">
@@ -696,34 +729,33 @@ export function SuiteIntegrationPicker({
696
729
  </span>
697
730
  </span>
698
731
  </button>
699
- {suiteAppsForIntegration(item.categories).length > 0 ? (
732
+ {bridgeApps.length > 0 ? (
700
733
  <div
701
734
  className="mt-3 flex flex-wrap items-center gap-1.5"
702
- aria-label="Works with ShellStack apps"
735
+ aria-label="Bridges into ShellStack apps"
703
736
  >
704
- {suiteAppsForIntegration(item.categories).map(
705
- (app) => (
706
- <span
737
+ {bridgeApps.map((app) => {
738
+ const mark = BRIDGE_MARK[app];
739
+ if (!mark) return null;
740
+ return (
741
+ <img
707
742
  key={app}
743
+ src={mark.src}
744
+ alt=""
745
+ title={`Bridge in ${mark.name}`}
746
+ className="h-5 w-5"
708
747
  data-test={`${dataTest}-${item.id}-app-${app}`}
709
- >
710
- <SuiteAppIcon
711
- app={app}
712
- size={16}
713
- label={`Works with ${SUITE_APP_MAP[app].name}`}
714
- className="rounded-[4px]"
715
- />
716
- </span>
717
- ),
718
- )}
748
+ />
749
+ );
750
+ })}
719
751
  </div>
720
752
  ) : null}
721
- {authSchemesFor(item).length > 0 ? (
753
+ {schemes.length > 0 ? (
722
754
  <div
723
755
  className="mt-3 flex flex-wrap gap-1.5"
724
756
  aria-label="Connection methods"
725
757
  >
726
- {authSchemesFor(item).map((scheme) => (
758
+ {schemes.map((scheme) => (
727
759
  <span
728
760
  key={scheme}
729
761
  className="rounded-full border border-ph-border bg-ph-muted px-2 py-1 text-[0.6875rem] font-medium text-ph-subtle"
@@ -1,10 +1,11 @@
1
- import { cleanup, render, waitFor } from "@testing-library/react";
1
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  vi.mock("next/navigation", () => ({
5
5
  useRouter: () => ({ push: vi.fn() }),
6
6
  }));
7
7
 
8
+ import { SuiteNotificationsProvider } from "../lib/suite-notifications";
8
9
  import { SuiteNotificationBell } from "./suite-notification-bell";
9
10
 
10
11
  afterEach(() => {
@@ -14,21 +15,24 @@ afterEach(() => {
14
15
  describe("SuiteNotificationBell", () => {
15
16
  it("anchors the unread badge close to the bell glyph", async () => {
16
17
  render(
17
- <SuiteNotificationBell
18
+ <SuiteNotificationsProvider
18
19
  fetchNotifications={async () => ({
19
20
  notifications: [],
20
21
  unread_count: 12,
21
22
  })}
22
23
  markRead={async () => undefined}
23
24
  markAllRead={async () => undefined}
24
- dropdownMenu={({ trigger, "aria-label": label, children }) => (
25
- <div aria-label={label}>
26
- {trigger}
27
- {children}
28
- </div>
29
- )}
30
25
  cacheKey={null}
31
- />,
26
+ >
27
+ <SuiteNotificationBell
28
+ dropdownMenu={({ trigger, "aria-label": label, children }) => (
29
+ <div aria-label={label}>
30
+ {trigger}
31
+ {children}
32
+ </div>
33
+ )}
34
+ />
35
+ </SuiteNotificationsProvider>,
32
36
  );
33
37
 
34
38
  const badge = await waitFor(() => {
@@ -44,4 +48,78 @@ describe("SuiteNotificationBell", () => {
44
48
  expect(badge.parentElement?.className).toContain("h-5");
45
49
  expect(badge.parentElement?.className).toContain("w-5");
46
50
  });
51
+
52
+ it("keeps each responsive bell dropdown open state independent", () => {
53
+ render(
54
+ <SuiteNotificationsProvider
55
+ fetchNotifications={async () => ({
56
+ notifications: [],
57
+ unread_count: 0,
58
+ })}
59
+ markRead={async () => undefined}
60
+ markAllRead={async () => undefined}
61
+ cacheKey={null}
62
+ >
63
+ <SuiteNotificationBell
64
+ dataTest="desktop-notifications"
65
+ dropdownMenu={({
66
+ trigger,
67
+ children,
68
+ open,
69
+ onOpenChange,
70
+ triggerId,
71
+ }) => (
72
+ <div>
73
+ <button
74
+ id={triggerId}
75
+ type="button"
76
+ onClick={() => onOpenChange?.(!open)}
77
+ >
78
+ {trigger}
79
+ </button>
80
+ {open ? (
81
+ <div data-test={`${triggerId}-panel`}>{children}</div>
82
+ ) : null}
83
+ </div>
84
+ )}
85
+ />
86
+ <SuiteNotificationBell
87
+ dataTest="mobile-notifications"
88
+ dropdownMenu={({
89
+ trigger,
90
+ children,
91
+ open,
92
+ onOpenChange,
93
+ triggerId,
94
+ }) => (
95
+ <div>
96
+ <button
97
+ id={triggerId}
98
+ type="button"
99
+ onClick={() => onOpenChange?.(!open)}
100
+ >
101
+ {trigger}
102
+ </button>
103
+ {open ? (
104
+ <div data-test={`${triggerId}-panel`}>{children}</div>
105
+ ) : null}
106
+ </div>
107
+ )}
108
+ />
109
+ </SuiteNotificationsProvider>,
110
+ );
111
+
112
+ fireEvent.click(document.getElementById("desktop-notifications-trigger")!);
113
+
114
+ expect(
115
+ document.querySelector(
116
+ '[data-test="desktop-notifications-trigger-panel"]',
117
+ ),
118
+ ).not.toBeNull();
119
+ expect(
120
+ document.querySelector(
121
+ '[data-test="mobile-notifications-trigger-panel"]',
122
+ ),
123
+ ).toBeNull();
124
+ });
47
125
  });