@shipfox/client-integrations 8.0.0 → 10.0.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
3
3
  "license": "MIT",
4
- "version": "8.0.0",
4
+ "version": "10.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -35,8 +35,8 @@
35
35
  "@shipfox/api-integration-sentry-dto": "9.0.2",
36
36
  "@shipfox/api-integration-webhook-dto": "9.0.2",
37
37
  "@shipfox/client-api": "6.0.1",
38
- "@shipfox/client-auth": "8.0.0",
39
- "@shipfox/client-shell": "8.0.0",
38
+ "@shipfox/client-auth": "10.0.0",
39
+ "@shipfox/client-shell": "10.0.0",
40
40
  "@shipfox/client-ui": "6.0.2",
41
41
  "@shipfox/integration-icons": "0.2.2",
42
42
  "@shipfox/react-ui": "0.3.7"
@@ -27,10 +27,10 @@ export function ConnectionPicker({
27
27
  aria-labelledby={labelId}
28
28
  value={selectedConnectionId ?? ''}
29
29
  onValueChange={onSelect}
30
- className="grid grid-cols-2 gap-8 min-[1200px]:grid-cols-3 max-[760px]:grid-cols-1"
30
+ className="grid grid-cols-2 gap-8 max-[760px]:grid-cols-1"
31
31
  >
32
32
  {connections.map((connection) => (
33
- <RadioGroupItem key={connection.id} value={connection.id} className="p-12">
33
+ <RadioGroupItem key={connection.id} value={connection.id}>
34
34
  <ConnectionOption connection={connection} />
35
35
  </RadioGroupItem>
36
36
  ))}
@@ -0,0 +1,145 @@
1
+ import {fireEvent, render, screen} from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import type {Repository} from '#core/models.js';
4
+ import {RepositoryPicker} from './repository-picker.js';
5
+
6
+ const originalScrollWidth = Object.getOwnPropertyDescriptor(
7
+ window.HTMLElement.prototype,
8
+ 'scrollWidth',
9
+ );
10
+ const originalClientWidth = Object.getOwnPropertyDescriptor(
11
+ window.HTMLElement.prototype,
12
+ 'clientWidth',
13
+ );
14
+
15
+ afterEach(() => {
16
+ restoreElementWidthDescriptors();
17
+ });
18
+
19
+ describe('RepositoryPicker', () => {
20
+ test('renders repository names without owner or default branch metadata', () => {
21
+ renderPicker({
22
+ repositories: [
23
+ repository({name: 'platform', fullName: 'acme/platform', defaultBranch: 'main'}),
24
+ ],
25
+ });
26
+
27
+ expect(screen.getByRole('radio', {name: 'platform'})).toBeInTheDocument();
28
+ expect(screen.queryByText('acme/platform')).not.toBeInTheDocument();
29
+ expect(screen.queryByText('main')).not.toBeInTheDocument();
30
+ });
31
+
32
+ test('shows the name-only tooltip when a repository name is truncated on hover', async () => {
33
+ setElementWidths({scrollWidth: 220, clientWidth: 100});
34
+ const user = userEvent.setup();
35
+ const repositoryName = 'acme-platform-infrastructure-control-plane';
36
+
37
+ renderPicker({repositories: [repository({name: repositoryName})]});
38
+ const radio = screen.getByRole('radio', {name: repositoryName});
39
+
40
+ await user.hover(radio);
41
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(repositoryName);
42
+ });
43
+
44
+ test('shows the name-only tooltip when a truncated repository name receives keyboard focus', async () => {
45
+ setElementWidths({scrollWidth: 220, clientWidth: 100});
46
+ const repositoryName = 'acme-platform-infrastructure-control-plane';
47
+
48
+ renderPicker({repositories: [repository({name: repositoryName})]});
49
+ const radio = screen.getByRole('radio', {name: repositoryName});
50
+
51
+ fireEvent.focus(radio);
52
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(repositoryName);
53
+ });
54
+
55
+ test('does not render tooltip content when a repository name is not truncated', () => {
56
+ setElementWidths({scrollWidth: 80, clientWidth: 100});
57
+
58
+ renderPicker({repositories: [repository({name: 'platform'})]});
59
+ fireEvent.focus(screen.getByRole('radio', {name: 'platform'}));
60
+
61
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
62
+ });
63
+
64
+ test('renders four accessible loading placeholders in the repository grid', () => {
65
+ const {container} = renderPicker({
66
+ repositories: [],
67
+ isLoading: true,
68
+ searchValue: '',
69
+ onSearchChange: () => undefined,
70
+ });
71
+
72
+ expect(screen.getByRole('status')).toHaveTextContent('Loading repositories.');
73
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
74
+
75
+ const loadingGrid = container.querySelector('[aria-hidden="true"]');
76
+ if (!loadingGrid) throw new Error('Repository loading grid was not rendered');
77
+
78
+ expect(loadingGrid).toHaveClass('grid', 'grid-cols-2', 'gap-8', 'max-[760px]:grid-cols-1');
79
+ expect(loadingGrid.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(4);
80
+ for (const placeholder of loadingGrid.querySelectorAll(':scope > div')) {
81
+ expect(placeholder).toHaveClass(
82
+ 'h-50',
83
+ 'rounded-8',
84
+ 'border',
85
+ 'border-border-neutral-base',
86
+ 'bg-background-neutral-base',
87
+ 'p-14',
88
+ );
89
+ }
90
+ });
91
+ });
92
+
93
+ function renderPicker(
94
+ props: Partial<Parameters<typeof RepositoryPicker>[0]> = {},
95
+ ): ReturnType<typeof render> {
96
+ return render(
97
+ <RepositoryPicker
98
+ repositories={[]}
99
+ selectedRepositoryId={undefined}
100
+ onSelect={() => undefined}
101
+ isLoading={false}
102
+ {...props}
103
+ />,
104
+ );
105
+ }
106
+
107
+ function repository(overrides: Partial<Repository> = {}): Repository {
108
+ return {
109
+ connectionId: 'connection-1',
110
+ externalRepositoryId: 'repository-1',
111
+ owner: 'acme',
112
+ name: 'platform',
113
+ fullName: 'acme/platform',
114
+ defaultBranch: 'main',
115
+ visibility: 'private',
116
+ cloneUrl: 'https://github.example.test/acme/platform.git',
117
+ htmlUrl: 'https://github.example.test/acme/platform',
118
+ ...overrides,
119
+ };
120
+ }
121
+
122
+ function setElementWidths(widths: {scrollWidth: number; clientWidth: number}) {
123
+ Object.defineProperty(window.HTMLElement.prototype, 'scrollWidth', {
124
+ configurable: true,
125
+ get: () => widths.scrollWidth,
126
+ });
127
+ Object.defineProperty(window.HTMLElement.prototype, 'clientWidth', {
128
+ configurable: true,
129
+ get: () => widths.clientWidth,
130
+ });
131
+ }
132
+
133
+ function restoreElementWidthDescriptors() {
134
+ if (originalScrollWidth) {
135
+ Object.defineProperty(window.HTMLElement.prototype, 'scrollWidth', originalScrollWidth);
136
+ } else {
137
+ delete (window.HTMLElement.prototype as {scrollWidth?: number}).scrollWidth;
138
+ }
139
+
140
+ if (originalClientWidth) {
141
+ Object.defineProperty(window.HTMLElement.prototype, 'clientWidth', originalClientWidth);
142
+ } else {
143
+ delete (window.HTMLElement.prototype as {clientWidth?: number}).clientWidth;
144
+ }
145
+ }
@@ -1,12 +1,17 @@
1
1
  import {Button} from '@shipfox/react-ui/button';
2
+ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
2
3
  import {Input} from '@shipfox/react-ui/input';
3
4
  import {Label} from '@shipfox/react-ui/label';
4
5
  import {RadioGroup, RadioGroupItem} from '@shipfox/react-ui/radio-group';
5
6
  import {Skeleton} from '@shipfox/react-ui/skeleton';
7
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
6
8
  import {Text} from '@shipfox/react-ui/typography';
7
9
  import {useId} from 'react';
8
10
  import type {Repository} from '#core/models.js';
9
11
 
12
+ const REPOSITORY_GRID_CLASS_NAME = 'grid grid-cols-2 gap-8 max-[760px]:grid-cols-1';
13
+ const REPOSITORY_SKELETON_WIDTHS = ['w-64', 'w-96', 'w-80', 'w-112'] as const;
14
+
10
15
  export function RepositoryPicker({
11
16
  repositories,
12
17
  selectedRepositoryId,
@@ -52,7 +57,7 @@ export function RepositoryPicker({
52
57
  />
53
58
  ) : null}
54
59
 
55
- {isLoading ? <Skeleton className="h-58 w-full" /> : null}
60
+ {isLoading ? <RepositoryLoadingState /> : null}
56
61
 
57
62
  {!isLoading && repositories.length === 0 ? (
58
63
  <div className="rounded-8 border border-border-neutral-base bg-background-subtle-base p-14">
@@ -65,23 +70,10 @@ export function RepositoryPicker({
65
70
  aria-labelledby={labelId}
66
71
  value={selectedRepositoryId ?? ''}
67
72
  onValueChange={onSelect}
68
- className="grid grid-cols-2 gap-8 min-[1200px]:grid-cols-3 max-[760px]:grid-cols-1"
73
+ className={REPOSITORY_GRID_CLASS_NAME}
69
74
  >
70
75
  {repositories.map((repository) => (
71
- <RadioGroupItem
72
- key={repository.externalRepositoryId}
73
- value={repository.externalRepositoryId}
74
- className="p-12"
75
- >
76
- <span className="flex min-w-0 items-center justify-between gap-10">
77
- <Text as="span" size="sm" bold className="truncate">
78
- {repository.fullName}
79
- </Text>
80
- <Text as="span" size="xs" className="shrink-0 text-foreground-neutral-muted">
81
- {repository.defaultBranch}
82
- </Text>
83
- </span>
84
- </RadioGroupItem>
76
+ <RepositoryCard key={repository.externalRepositoryId} repository={repository} />
85
77
  ))}
86
78
  </RadioGroup>
87
79
  ) : null}
@@ -100,3 +92,42 @@ export function RepositoryPicker({
100
92
  </div>
101
93
  );
102
94
  }
95
+
96
+ function RepositoryCard({repository}: {repository: Repository}) {
97
+ const {ref: nameRef, isTruncated} = useIsTextTruncated<HTMLSpanElement>(repository.name);
98
+
99
+ return (
100
+ <Tooltip>
101
+ <TooltipTrigger asChild>
102
+ <RadioGroupItem value={repository.externalRepositoryId} className="min-w-0">
103
+ <span ref={nameRef} className="block min-w-0 truncate">
104
+ <Text as="span" size="sm" bold>
105
+ {repository.name}
106
+ </Text>
107
+ </span>
108
+ </RadioGroupItem>
109
+ </TooltipTrigger>
110
+ {isTruncated ? <TooltipContent>{repository.name}</TooltipContent> : null}
111
+ </Tooltip>
112
+ );
113
+ }
114
+
115
+ function RepositoryLoadingState() {
116
+ return (
117
+ <>
118
+ <div role="status" className="sr-only">
119
+ Loading repositories.
120
+ </div>
121
+ <div aria-hidden="true" className={REPOSITORY_GRID_CLASS_NAME}>
122
+ {REPOSITORY_SKELETON_WIDTHS.map((width) => (
123
+ <div
124
+ key={width}
125
+ className="h-50 min-w-0 rounded-8 border border-border-neutral-base bg-background-neutral-base p-14"
126
+ >
127
+ <Skeleton className={`h-20 ${width}`} />
128
+ </div>
129
+ ))}
130
+ </div>
131
+ </>
132
+ );
133
+ }