Skip to content

Commit adf5a16

Browse files
committed
feat: Add Bitbucket integration card and configuration modal
- Implemented BitbucketIntegrationCard component for displaying integration status and actions. - Added integration actions for linking and unlinking Bitbucket accounts with appropriate loading states and notifications. - Created ConfigureBitbucketModalBody component for user input to link Bitbucket accounts, including validation for username, password, and custom domain. - Integrated error handling and user feedback through snackbar notifications. - Included visual representation of required permissions for Bitbucket App Passwords.
1 parent 7aab98d commit adf5a16

File tree

9 files changed

+637
-7
lines changed

9 files changed

+637
-7
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Endpoint, nullSchema } from '@/api-helpers/global';
2+
import { handleRequest } from '@/api-helpers/axios';
3+
import * as yup from 'yup';
4+
5+
const payloadSchema = yup.object({
6+
username: yup.string().required('Username is required'),
7+
appPassword: yup.string().required('App password is required'),
8+
customDomain: yup.string().url('Custom domain must be a valid URL'),
9+
});
10+
11+
const endpoint = new Endpoint(nullSchema);
12+
13+
endpoint.handle.POST(payloadSchema, async (req, res) => {
14+
try {
15+
const { username, appPassword, customDomain } = req.payload;
16+
const baseUrl = customDomain || 'https://api.bitbucket.org/2.0';
17+
const url = `${baseUrl}/user`;
18+
const response = await handleRequest(url, {
19+
method: 'GET',
20+
auth: {
21+
username,
22+
password: appPassword,
23+
},
24+
},true);
25+
26+
res.status(200).json(response);
27+
} catch (error: any) {
28+
console.error('Error fetching Bitbucket user:', error.message);
29+
res.status(error.response?.status || 500).json({
30+
message: error.response?.data?.error?.message || 'Internal Server Error',
31+
});
32+
}
33+
});
34+
35+
export default endpoint.serve();

web-server/pages/integrations.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ROUTES } from '@/constants/routes';
1313
import { FetchState } from '@/constants/ui-states';
1414
import { GithubIntegrationCard } from '@/content/Dashboards/GithubIntegrationCard';
1515
import { GitlabIntegrationCard } from '@/content/Dashboards/GitlabIntegrationCard';
16+
import { BitbucketIntegrationCard } from '@/content/Dashboards/BitbucketIntegrationCard';
1617
import { PageWrapper } from '@/content/PullRequests/PageWrapper';
1718
import { useAuth } from '@/hooks/useAuth';
1819
import { useBoolState, useEasyState } from '@/hooks/useEasyState';
@@ -163,6 +164,7 @@ const Content = () => {
163164
<FlexBox gap={2}>
164165
<GithubIntegrationCard />
165166
<GitlabIntegrationCard />
167+
<BitbucketIntegrationCard/>
166168
</FlexBox>
167169
{showCreationCTA && (
168170
<FlexBox mt={'56px'} col fit alignStart>
59.5 KB
Loading

web-server/src/api-helpers/axios.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,25 @@ const bffInterceptor = loggerInterceptor('bff');
8383

8484
internal.interceptors.request.use(bffInterceptor);
8585

86-
export const handleRequest = <T = any>(
86+
export const handleRequest = <T = any, B extends boolean = false>(
8787
url: string,
88-
params: AxiosRequestConfig<any> = { method: 'get' }
89-
): Promise<T> =>
88+
params: AxiosRequestConfig<any> = { method: 'get' },
89+
includeHeaders: B = false as B
90+
): Promise<B extends true ? { data: T; headers: any } : T> =>
9091
internal({
9192
url,
9293
...params,
9394
headers: { 'Content-Type': 'application/json' }
9495
})
95-
.then(handleThen)
96+
.then((r: any) => handleThen(r, includeHeaders))
9697
.catch(handleCatch);
9798

98-
export const handleThen = (r: AxiosResponse) => r.data;
99+
export const handleThen = <T = any, B extends boolean = false>(
100+
r: AxiosResponse<T>,
101+
includeHeaders: B = false as B
102+
): B extends true ? { data: T; headers: any } : T =>
103+
(includeHeaders ? { data: r.data, headers: r.headers } : r.data) as any;
104+
99105
export const handleCatch = (r: { response: AxiosResponse }) => {
100106
throw r.response;
101107
};
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import {
2+
ArrowForwardIosRounded,
3+
ChevronRightRounded,
4+
SettingsRounded
5+
} from '@mui/icons-material';
6+
import { Button, useTheme } from '@mui/material';
7+
import CircularProgress from '@mui/material/CircularProgress';
8+
import { useSnackbar } from 'notistack';
9+
import { FC, ReactNode, useEffect } from 'react';
10+
11+
import { FlexBox } from '@/components/FlexBox';
12+
import { Line } from '@/components/Text';
13+
import { track } from '@/constants/events';
14+
import { FetchState } from '@/constants/ui-states';
15+
import { bitBucketIntegrationDisplay } from '@/content/Dashboards/githubIntegration';
16+
import { useIntegrationHandlers } from '@/content/Dashboards/useIntegrationHandlers';
17+
import { useAuth } from '@/hooks/useAuth';
18+
import { useBoolState } from '@/hooks/useEasyState';
19+
import { fetchCurrentOrg } from '@/slices/auth';
20+
import { useDispatch, useSelector } from '@/store';
21+
22+
const cardRadius = 10.5;
23+
const cardBorder = 1.5;
24+
const getRadiusWithPadding = (radius: number, padding: number) =>
25+
`${radius + padding}px`;
26+
27+
export const BitbucketIntegrationCard = () => {
28+
const theme = useTheme();
29+
const { integrations } = useAuth();
30+
const isBitbucketIntegrated = integrations.bitbucket;
31+
const sliceLoading = useSelector(
32+
(s: { auth: { requests: { org: FetchState; }; }; }) => s.auth.requests.org === FetchState.REQUEST
33+
);
34+
const { link, unlink } = useIntegrationHandlers();
35+
36+
const localLoading = useBoolState(false);
37+
38+
const isLoading = sliceLoading || localLoading.value;
39+
40+
const dispatch = useDispatch();
41+
42+
const { enqueueSnackbar } = useSnackbar();
43+
44+
return (
45+
<FlexBox relative>
46+
{isBitbucketIntegrated && (
47+
<FlexBox
48+
title="Linked"
49+
sx={{
50+
position: 'absolute',
51+
right: '-6px',
52+
top: '-6px',
53+
zIndex: 2
54+
}}
55+
>
56+
<LinkedIcon />
57+
</FlexBox>
58+
)}
59+
<FlexBox
60+
p={`${cardBorder}px`}
61+
corner={getRadiusWithPadding(cardRadius, cardBorder)}
62+
sx={{ background: bitBucketIntegrationDisplay.bg }}
63+
relative
64+
overflow={'unset'}
65+
>
66+
<FlexBox
67+
height="120px"
68+
width="280px"
69+
corner={`${cardRadius}px`}
70+
col
71+
p={1.5}
72+
relative
73+
bgcolor={theme.palette.background.default}
74+
>
75+
<FlexBox
76+
position="absolute"
77+
fill
78+
top={0}
79+
left={0}
80+
sx={{ opacity: 0.2, background: bitBucketIntegrationDisplay.bg }}
81+
/>
82+
<FlexBox alignCenter gap1 fit>
83+
<FlexBox fit color={bitBucketIntegrationDisplay.color}>
84+
{bitBucketIntegrationDisplay.icon}
85+
</FlexBox>
86+
<Line big medium white>
87+
{bitBucketIntegrationDisplay.name}
88+
</Line>
89+
</FlexBox>
90+
<FlexBox alignCenter gap1 mt="auto">
91+
<IntegrationActionsButton
92+
onClick={async () => {
93+
track(
94+
isBitbucketIntegrated
95+
? 'INTEGRATION_UNLINK_TRIGGERED'
96+
: 'INTEGRATION_LINK_TRIGGERED',
97+
{ integration_name: bitBucketIntegrationDisplay.name }
98+
);
99+
if (!isBitbucketIntegrated) {
100+
link.bitbucket();
101+
return;
102+
}
103+
const shouldExecute = window.confirm(
104+
'Are you sure you want to unlink?'
105+
);
106+
if (shouldExecute) {
107+
localLoading.true();
108+
await unlink
109+
.bitbucket()
110+
.then(() => {
111+
enqueueSnackbar('Bitbucket unlinked successfully', {
112+
variant: 'success'
113+
});
114+
})
115+
.then(async () => dispatch(fetchCurrentOrg()))
116+
.catch((e: any) => {
117+
console.error('Failed to unlink Bitbucket', e);
118+
enqueueSnackbar('Failed to unlink Bitbucket', {
119+
variant: 'error'
120+
});
121+
})
122+
.finally(localLoading.false);
123+
}
124+
}}
125+
label={!isBitbucketIntegrated ? 'Link' : 'Unlink'}
126+
bgOpacity={!isBitbucketIntegrated ? 0.45 : 0.25}
127+
endIcon={
128+
isLoading ? (
129+
<CircularProgress
130+
size={theme.spacing(1)}
131+
sx={{ ml: 1 / 2 }}
132+
/>
133+
) : (
134+
<ChevronRightRounded
135+
fontSize="small"
136+
sx={{ ml: 1 / 2, mr: -2 / 3 }}
137+
/>
138+
)
139+
}
140+
minWidth="72px"
141+
/>
142+
</FlexBox>
143+
</FlexBox>
144+
</FlexBox>
145+
</FlexBox>
146+
);
147+
};
148+
149+
const IntegrationActionsButton: FC<{
150+
onClick: AnyFunction;
151+
label: ReactNode;
152+
bgOpacity?: number;
153+
startIcon?: ReactNode;
154+
endIcon?: ReactNode;
155+
minWidth?: string;
156+
}> = ({
157+
label,
158+
onClick,
159+
bgOpacity = 0.45,
160+
endIcon = (
161+
<ArrowForwardIosRounded sx={{ fontSize: '0.9em' }} htmlColor="white" />
162+
),
163+
startIcon = <SettingsRounded sx={{ fontSize: '1em' }} htmlColor="white" />,
164+
minWidth = '80px'
165+
}) => {
166+
const theme = useTheme();
167+
168+
return (
169+
<Button
170+
variant="text"
171+
sx={{
172+
p: '1px',
173+
minWidth: 0,
174+
background: bitBucketIntegrationDisplay.bg,
175+
position: 'relative',
176+
borderRadius: getRadiusWithPadding(6, 1),
177+
fontSize: '0.9em'
178+
}}
179+
onClick={onClick}
180+
>
181+
<FlexBox
182+
position="absolute"
183+
fill
184+
top={0}
185+
left={0}
186+
sx={{
187+
opacity: bgOpacity,
188+
background: bitBucketIntegrationDisplay.bg,
189+
transition: 'all 0.2s',
190+
':hover': {
191+
opacity: bgOpacity * 0.6
192+
}
193+
}}
194+
corner="6px"
195+
/>
196+
<FlexBox
197+
bgcolor={theme.palette.background.default}
198+
px={1}
199+
py={1 / 4}
200+
corner="6px"
201+
color="white"
202+
alignCenter
203+
gap={1 / 4}
204+
minWidth={minWidth}
205+
>
206+
{startIcon}
207+
<Line mr="auto">{label}</Line>
208+
{endIcon}
209+
</FlexBox>
210+
</Button>
211+
);
212+
};
213+
214+
const LinkedIcon = () => {
215+
const isVisible = useBoolState(false);
216+
useEffect(() => {
217+
setTimeout(isVisible.true, 200);
218+
}, [isVisible.true]);
219+
return (
220+
<svg
221+
style={{
222+
opacity: isVisible.value ? 1 : 0,
223+
transform: isVisible.value ? 'scale(1)' : 'scale(0)',
224+
transition: 'all 0.2s ease'
225+
}}
226+
width="26"
227+
height="26"
228+
viewBox="0 0 26 26"
229+
fill="none"
230+
xmlns="http://www.w3.org/2000/svg"
231+
>
232+
<g clipPath="url(#clip0_211_974)">
233+
<path
234+
fillRule="evenodd"
235+
clipRule="evenodd"
236+
d="M0 13C0 9.55219 1.36964 6.24558 3.80761 3.80761C6.24558 1.36964 9.55219 0 13 0C16.4478 0 19.7544 1.36964 22.1924 3.80761C24.6304 6.24558 26 9.55219 26 13C26 16.4478 24.6304 19.7544 22.1924 22.1924C19.7544 24.6304 16.4478 26 13 26C9.55219 26 6.24558 24.6304 3.80761 22.1924C1.36964 19.7544 0 16.4478 0 13ZM12.2581 18.564L19.7427 9.20747L18.3907 8.12587L12.0085 16.1009L7.488 12.3344L6.37867 13.6656L12.2581 18.564Z"
237+
fill="#14AE5C"
238+
/>
239+
</g>
240+
<defs>
241+
<clipPath id="clip0_211_974">
242+
<rect width="26" height="26" fill="white" />
243+
</clipPath>
244+
</defs>
245+
</svg>
246+
);
247+
};
248+

0 commit comments

Comments
 (0)