@stackone/hub 0.1.0 → 0.2.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.
@@ -1 +1 @@
1
- {".":"0.1.0"}
1
+ {".":"0.2.0"}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/StackOneHQ/hub/compare/hub-v0.1.0...hub-v0.2.0) (2025-08-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * add external trigger token rendering ([f1c0ea2](https://github.com/StackOneHQ/hub/commit/f1c0ea22496b4b0bd92a33d69e4af5492b96089c))
9
+ * expression and conditional rendering ([1762d3a](https://github.com/StackOneHQ/hub/commit/1762d3aad03fccc55a606bb5e154b85e8289f000))
10
+ * feature flag context and hook ([a9d00a9](https://github.com/StackOneHQ/hub/commit/a9d00a9e81f3276c82f5e98c26e0b5648a4ad0cd))
11
+ * render dropdowns and textareas with malachite ([9e59a0d](https://github.com/StackOneHQ/hub/commit/9e59a0d864af6c06cc7b653ff393291877895812))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * add border to dropdown ([2fb57e1](https://github.com/StackOneHQ/hub/commit/2fb57e139be16e646d3404bd5e13fd010cae8e67))
17
+ * add theme support again ([76a092b](https://github.com/StackOneHQ/hub/commit/76a092b3625ac850af9b3256569337cacd92da52))
18
+ * build ([1eb7894](https://github.com/StackOneHQ/hub/commit/1eb78945d7274349cb4572bac79ecd3e0929a57a))
19
+ * handle window close event ([617d999](https://github.com/StackOneHQ/hub/commit/617d9992c03496dab0b5e7bdba149b0f78314aee))
20
+ * input loop and card style ([a91e72a](https://github.com/StackOneHQ/hub/commit/a91e72a253a0da529712867e27192a397ed89ddf))
21
+ * key uniqueness ([c3d01c5](https://github.com/StackOneHQ/hub/commit/c3d01c513fd6d4b0e8526c6741b2ae2272ef2f0a))
22
+
3
23
  ## [0.1.0](https://github.com/StackOneHQ/hub/compare/hub-v0.0.1...hub-v0.1.0) (2025-07-11)
4
24
 
5
25
 
package/dev/main.tsx CHANGED
@@ -11,8 +11,9 @@ const HubWrapper: React.FC = () => {
11
11
  const [error, setError] = useState<string>();
12
12
  const [token, setToken] = useState<string>();
13
13
  const apiUrl = import.meta.env.VITE_API_URL ?? 'https://api.stackone.com';
14
+ const appUrl = import.meta.env.VITE_APP_URL ?? 'https://app.stackone.com';
14
15
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
15
- const [accountId, setAccountId] = useState<string>('46071458593115456017');
16
+ const [accountId, setAccountId] = useState<string>();
16
17
 
17
18
  const fetchToken = useCallback(async () => {
18
19
  try {
@@ -81,6 +82,7 @@ const HubWrapper: React.FC = () => {
81
82
  setMode(undefined);
82
83
  }}
83
84
  accountId={accountId}
85
+ appUrl={appUrl}
84
86
  />
85
87
  </div>
86
88
  );
package/dev/vite-env.d.ts CHANGED
@@ -7,7 +7,7 @@ interface ImportMetaEnv {
7
7
  readonly VITE_ORIGIN_OWNER_NAME: string;
8
8
  readonly VITE_ORIGIN_USERNAME: string;
9
9
  readonly VITE_API_URL: string;
10
- readonly VITE_DASHBOARD_URL: string;
10
+ readonly VITE_APP_URL: string;
11
11
  }
12
12
 
13
13
  export interface ImportMeta {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackone/hub",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "StackOne HUB",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,10 +16,14 @@
16
16
  "author": "StackOne",
17
17
  "license": "MIT",
18
18
  "dependencies": {
19
- "@stackone/malachite": "^0.1.1",
20
- "@tanstack/react-query": "^5.77.2",
21
- "react": "^18.0.0",
22
- "react-dom": "^18.1.0"
19
+ "@stackone/expressions": "^0.16.0",
20
+ "@stackone/malachite": "^0.3.1",
21
+ "@tanstack/react-query": "^5.77.2"
22
+ },
23
+ "peerDependencies": {
24
+ "react": "18.3.1",
25
+ "react-dom": "18.3.1",
26
+ "react-hook-form": "7.60.0"
23
27
  },
24
28
  "devDependencies": {
25
29
  "@biomejs/biome": "1.9.4",
@@ -29,8 +33,8 @@
29
33
  "@rollup/plugin-terser": "^0.4.4",
30
34
  "@rollup/plugin-typescript": "^12.1.2",
31
35
  "@types/node": "^22.15.17",
32
- "@types/react": "^18.0.0",
33
- "@types/react-dom": "^18.0.0",
36
+ "@types/react": "^18.3.23",
37
+ "@types/react-dom": "^18.3.7",
34
38
  "@vitejs/plugin-react-swc": "^3.9.0",
35
39
  "react-to-webcomponent": "^2.0.1",
36
40
  "rollup": "^4.40.2",
@@ -41,4 +45,4 @@
41
45
  "tslib": "^2.8.1",
42
46
  "typescript": "^5.8.3"
43
47
  }
44
- }
48
+ }
package/src/Hub.tsx ADDED
@@ -0,0 +1,50 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { IntegrationPicker } from './modules/integration-picker/IntegrationPicker';
3
+ import { FeatureFlagProvider } from './shared/contexts/featureFlagContext';
4
+ import { getSettings } from './shared/queries';
5
+ import { HubModes } from './types/types';
6
+
7
+ interface HubProps {
8
+ mode: HubModes;
9
+ token: string;
10
+ apiUrl: string;
11
+ dashboardUrl: string;
12
+ height: string;
13
+ onSuccess?: () => void;
14
+ onClose?: () => void;
15
+ onCancel?: () => void;
16
+ accountId?: string;
17
+ }
18
+ export const Hub = ({
19
+ mode,
20
+ token,
21
+ apiUrl,
22
+ dashboardUrl,
23
+ height,
24
+ onSuccess,
25
+ onClose,
26
+ onCancel,
27
+ accountId,
28
+ }: HubProps) => {
29
+ const { data: settings } = useQuery({
30
+ queryKey: ['settings'],
31
+ queryFn: () => getSettings(apiUrl, token),
32
+ });
33
+
34
+ return (
35
+ <FeatureFlagProvider featureFlags={settings?.enabled_features ?? []}>
36
+ {mode === 'integration-picker' && (
37
+ <IntegrationPicker
38
+ token={token}
39
+ baseUrl={apiUrl}
40
+ dashboardUrl={dashboardUrl}
41
+ height={height}
42
+ onSuccess={onSuccess}
43
+ onClose={onClose}
44
+ onCancel={onCancel}
45
+ accountId={accountId}
46
+ />
47
+ )}
48
+ </FeatureFlagProvider>
49
+ );
50
+ };
@@ -4,10 +4,16 @@ import {
4
4
  FlexAlign,
5
5
  FlexJustify,
6
6
  FooterLinks,
7
- ThemeProvider,
7
+ MalachiteContext,
8
+ PartialMalachiteTheme,
8
9
  Typography,
10
+ applyDarkTheme,
11
+ applyLightTheme,
12
+ applyTheme,
9
13
  } from '@stackone/malachite';
10
14
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
15
+ import { useEffect } from 'react';
16
+ import { Hub } from './Hub';
11
17
  import { CsvImporter } from './modules/csv-importer.tsx/CsvImporter';
12
18
  import { IntegrationPicker } from './modules/integration-picker/IntegrationPicker';
13
19
  import ErrorContainer from './shared/components/error';
@@ -18,8 +24,9 @@ interface StackOneHubProps {
18
24
  mode?: HubModes;
19
25
  token?: string;
20
26
  baseUrl?: string;
27
+ appUrl?: string;
21
28
  height?: string;
22
- theme?: 'light' | 'dark';
29
+ theme?: 'light' | 'dark' | PartialMalachiteTheme;
23
30
  accountId?: string;
24
31
  onSuccess?: () => void;
25
32
  onClose?: () => void;
@@ -30,6 +37,7 @@ export const StackOneHub: React.FC<StackOneHubProps> = ({
30
37
  mode,
31
38
  token,
32
39
  baseUrl,
40
+ appUrl,
33
41
  height = '500px',
34
42
  theme = 'light',
35
43
  accountId,
@@ -39,6 +47,17 @@ export const StackOneHub: React.FC<StackOneHubProps> = ({
39
47
  }) => {
40
48
  const defaultBaseUrl = 'https://api.stackone.com';
41
49
  const apiUrl = baseUrl ?? defaultBaseUrl;
50
+ const defaultDashboardUrl = 'https://app.stackone.com';
51
+ const dashboardUrl = appUrl ?? defaultDashboardUrl;
52
+ useEffect(() => {
53
+ if (theme === 'dark') {
54
+ applyDarkTheme();
55
+ } else if (theme === 'light') {
56
+ applyLightTheme();
57
+ } else {
58
+ applyTheme(theme);
59
+ }
60
+ }, [theme]);
42
61
 
43
62
  const queryClient = new QueryClient({
44
63
  defaultOptions: {
@@ -54,25 +73,45 @@ export const StackOneHub: React.FC<StackOneHubProps> = ({
54
73
 
55
74
  if (!token) {
56
75
  return (
57
- <Card height={height} footer={<FooterLinks />}>
58
- <Flex justify={FlexJustify.Center} align={FlexAlign.Center} fullHeight>
59
- <Typography.PageTitle>No token provided</Typography.PageTitle>
60
- </Flex>
61
- </Card>
76
+ <MalachiteContext>
77
+ <ErrorBoundary
78
+ fallback={
79
+ <Card height={height}>
80
+ <ErrorContainer />
81
+ </Card>
82
+ }
83
+ >
84
+ <Card height={height} footer={<FooterLinks />}>
85
+ <Flex justify={FlexJustify.Center} align={FlexAlign.Center} fullHeight>
86
+ <Typography.PageTitle>No token provided</Typography.PageTitle>
87
+ </Flex>
88
+ </Card>
89
+ </ErrorBoundary>
90
+ </MalachiteContext>
62
91
  );
63
92
  }
64
93
  if (!mode) {
65
94
  return (
66
- <Card height={height} footer={<FooterLinks />}>
67
- <Flex justify={FlexJustify.Center} align={FlexAlign.Center} fullHeight>
68
- <Typography.PageTitle>No mode selected</Typography.PageTitle>
69
- </Flex>
70
- </Card>
95
+ <MalachiteContext>
96
+ <ErrorBoundary
97
+ fallback={
98
+ <Card height={height}>
99
+ <ErrorContainer />
100
+ </Card>
101
+ }
102
+ >
103
+ <Card height={height} footer={<FooterLinks />}>
104
+ <Flex justify={FlexJustify.Center} align={FlexAlign.Center} fullHeight>
105
+ <Typography.PageTitle>No mode selected</Typography.PageTitle>
106
+ </Flex>
107
+ </Card>
108
+ </ErrorBoundary>
109
+ </MalachiteContext>
71
110
  );
72
111
  }
73
112
 
74
113
  return (
75
- <ThemeProvider theme={theme}>
114
+ <MalachiteContext>
76
115
  <ErrorBoundary
77
116
  fallback={
78
117
  <Card height={height}>
@@ -81,19 +120,19 @@ export const StackOneHub: React.FC<StackOneHubProps> = ({
81
120
  }
82
121
  >
83
122
  <QueryClientProvider client={queryClient}>
84
- {mode === 'integration-picker' && (
85
- <IntegrationPicker
86
- token={token}
87
- baseUrl={apiUrl}
88
- height={height}
89
- onSuccess={onSuccess}
90
- onClose={onClose}
91
- onCancel={onCancel}
92
- accountId={accountId}
93
- />
94
- )}
123
+ <Hub
124
+ mode={mode}
125
+ token={token}
126
+ apiUrl={apiUrl}
127
+ dashboardUrl={dashboardUrl}
128
+ height={height}
129
+ onSuccess={onSuccess}
130
+ accountId={accountId}
131
+ onClose={onClose}
132
+ onCancel={onCancel}
133
+ />
95
134
  </QueryClientProvider>
96
135
  </ErrorBoundary>
97
- </ThemeProvider>
136
+ </MalachiteContext>
98
137
  );
99
138
  };
@@ -1,4 +1,5 @@
1
1
  import { Card } from '@stackone/malachite';
2
+ import useFeatureFlags from '../../shared/hooks/useFeatureFlags';
2
3
  import { IntegrationPickerContent } from './components/IntegrationPickerContent';
3
4
  import CardFooter from './components/cardFooter';
4
5
  import CardTitle from './components/cardTitle';
@@ -9,6 +10,7 @@ interface IntegrationPickerProps {
9
10
  baseUrl: string;
10
11
  height: string;
11
12
  accountId?: string;
13
+ dashboardUrl?: string;
12
14
  onSuccess?: () => void;
13
15
  onClose?: () => void;
14
16
  onCancel?: () => void;
@@ -20,7 +22,10 @@ export const IntegrationPicker: React.FC<IntegrationPickerProps> = ({
20
22
  height,
21
23
  accountId,
22
24
  onSuccess,
25
+ dashboardUrl,
23
26
  }) => {
27
+ const isHubLinkAccountReleaseEnabled = useFeatureFlags('hub_link_account_release');
28
+
24
29
  const {
25
30
  // Data
26
31
  hubData,
@@ -48,6 +53,7 @@ export const IntegrationPicker: React.FC<IntegrationPickerProps> = ({
48
53
  baseUrl,
49
54
  accountId,
50
55
  onSuccess,
56
+ dashboardUrl,
51
57
  });
52
58
 
53
59
  return (
@@ -70,20 +76,22 @@ export const IntegrationPicker: React.FC<IntegrationPickerProps> = ({
70
76
  }
71
77
  height={height}
72
78
  >
73
- <IntegrationPickerContent
74
- isLoading={isLoading}
75
- hasError={hasError}
76
- connectionState={connectionState}
77
- selectedIntegration={selectedIntegration}
78
- connectorData={connectorData ?? null}
79
- hubData={hubData ?? null}
80
- fields={fields}
81
- guide={guide}
82
- errorHubData={errorHubData}
83
- errorConnectorData={errorConnectorData}
84
- onSelect={setSelectedIntegration}
85
- onChange={setFormData}
86
- />
79
+ {isHubLinkAccountReleaseEnabled && (
80
+ <IntegrationPickerContent
81
+ isLoading={isLoading}
82
+ hasError={hasError}
83
+ connectionState={connectionState}
84
+ selectedIntegration={selectedIntegration}
85
+ connectorData={connectorData?.config ?? null}
86
+ hubData={hubData ?? null}
87
+ fields={fields}
88
+ guide={guide}
89
+ errorHubData={errorHubData}
90
+ errorConnectorData={errorConnectorData}
91
+ onSelect={setSelectedIntegration}
92
+ onChange={setFormData}
93
+ />
94
+ )}
87
95
  </Card>
88
96
  );
89
97
  };
@@ -1,4 +1,4 @@
1
- import { Alert, Form, Input, Spacer, Typography } from '@stackone/malachite';
1
+ import { Alert, Dropdown, Form, Input, Spacer, TextArea, Typography } from '@stackone/malachite';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { ConnectorConfigField } from '../types';
4
4
 
@@ -29,6 +29,7 @@ export const IntegrationForm: React.FC<IntegrationFieldsProps> = ({
29
29
  return initialData;
30
30
  });
31
31
 
32
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
32
33
  useEffect(() => {
33
34
  const updatedData: Record<string, string> = {};
34
35
  fields.forEach((field) => {
@@ -37,14 +38,17 @@ export const IntegrationForm: React.FC<IntegrationFieldsProps> = ({
37
38
  }
38
39
  });
39
40
 
40
- const hasChanges =
41
- Object.keys(updatedData).some((key) => updatedData[key] !== formData[key]) ||
42
- Object.keys(formData).some((key) => !updatedData.hasOwnProperty(key));
41
+ setFormData((prev) => {
42
+ const hasChanges =
43
+ Object.keys(updatedData).some((key) => updatedData[key] !== prev[key]) ||
44
+ Object.keys(prev).some((key) => !updatedData.hasOwnProperty(key));
43
45
 
44
- if (hasChanges) {
45
- setFormData((prev) => ({ ...prev, ...updatedData }));
46
- }
47
- }, [fields, formData]);
46
+ if (hasChanges) {
47
+ return { ...prev, ...updatedData };
48
+ }
49
+ return prev;
50
+ });
51
+ }, [fields.length]);
48
52
 
49
53
  useEffect(() => {
50
54
  onChange(formData);
@@ -58,58 +62,68 @@ export const IntegrationForm: React.FC<IntegrationFieldsProps> = ({
58
62
  };
59
63
 
60
64
  return (
61
- <div>
62
- <Spacer direction="vertical" size={8}>
63
- {guide && <Alert type="info" message={guide?.description} hasMargin={false} />}
64
- {error && <Alert type="error" message={error.message} hasMargin={false} />}
65
- {error && <Typography.CodeText>{error.provider_response}</Typography.CodeText>}
66
- <Form>
67
- <Spacer direction="vertical" size={20}>
68
- {fields.map((field) => {
69
- return (
70
- <div key={field.key}>
71
- {(field.type === 'text' ||
72
- field.type === 'number' ||
73
- field.type === 'password' ||
74
- field.type === 'text_area') && (
75
- <Input
76
- name={field.key}
77
- required={field.required}
78
- placeholder={field.placeholder}
79
- defaultValue={field.value?.toString()}
80
- onChange={(value) =>
81
- handleFieldChange(field.key, value)
82
- }
83
- disabled={field.readOnly}
84
- label={field.label}
85
- tooltip={field.guide?.tooltip}
86
- description={field.guide?.description}
87
- />
88
- )}
65
+ <Spacer direction="vertical" size={8} fullWidth>
66
+ {guide && <Alert type="info" message={guide?.description} hasMargin={false} />}
67
+ {error && <Alert type="error" message={error.message} hasMargin={false} />}
68
+ {error && <Typography.CodeText>{error.provider_response}</Typography.CodeText>}
69
+ <Spacer direction="vertical" size={20} fullWidth>
70
+ {fields.map((field) => {
71
+ const key =
72
+ typeof field.key === 'object'
73
+ ? JSON.stringify(field.key)
74
+ : String(field.key);
75
+ return (
76
+ <div key={key} style={{ width: '100%' }}>
77
+ {(field.type === 'text' ||
78
+ field.type === 'number' ||
79
+ field.type === 'password') && (
80
+ <Input
81
+ name={key}
82
+ required={field.required}
83
+ placeholder={field.placeholder}
84
+ defaultValue={field.value?.toString()}
85
+ onChange={(value: string) => handleFieldChange(key, value)}
86
+ disabled={field.readOnly}
87
+ label={field.label}
88
+ tooltip={field.guide?.tooltip}
89
+ description={field.guide?.description}
90
+ type={field.type}
91
+ />
92
+ )}
89
93
 
90
- {field.type === 'select' && (
91
- <select
92
- name={field.key}
93
- required={field.required}
94
- value={formData[field.key] || ''}
95
- onChange={(e) =>
96
- handleFieldChange(field.key, e.target.value)
97
- }
98
- disabled={field.readOnly}
99
- >
100
- {field.options?.map((option) => (
101
- <option key={option.value} value={option.value}>
102
- {option.label}
103
- </option>
104
- ))}
105
- </select>
106
- )}
107
- </div>
108
- );
109
- })}
110
- </Spacer>
111
- </Form>
94
+ {field.type === 'text_area' && (
95
+ <TextArea
96
+ name={key}
97
+ required={field.required}
98
+ defaultValue={formData[key] || ''}
99
+ placeholder={field.placeholder}
100
+ onChange={(value: string) => handleFieldChange(key, value)}
101
+ disabled={field.readOnly}
102
+ label={field.label}
103
+ tooltip={field.guide?.tooltip}
104
+ />
105
+ )}
106
+ {field.type === 'select' && (
107
+ <Dropdown
108
+ defaultValue={formData[key] || ''}
109
+ disabled={field.readOnly}
110
+ items={
111
+ field.options?.map((option) => ({
112
+ id: option.value,
113
+ label: option.label,
114
+ })) ?? []
115
+ }
116
+ onItemSelected={(value) => handleFieldChange(key, value ?? '')}
117
+ name={key}
118
+ label={field.label}
119
+ tooltip={field.guide?.tooltip}
120
+ description={field.guide?.description}
121
+ />
122
+ )}
123
+ </div>
124
+ );
125
+ })}
112
126
  </Spacer>
113
- </div>
127
+ </Spacer>
114
128
  );
115
129
  };
@@ -32,15 +32,7 @@ const CardFooter: React.FC<CardFooterProps> = ({
32
32
  onClick: () => void;
33
33
  disabled: boolean;
34
34
  loading: boolean;
35
- }> = [
36
- {
37
- label: 'Next',
38
- type: 'filled' as const,
39
- onClick: onNext,
40
- disabled: false,
41
- loading: false,
42
- },
43
- ];
35
+ }> = [];
44
36
 
45
37
  if (onBack) {
46
38
  buttons.push({
@@ -52,6 +44,14 @@ const CardFooter: React.FC<CardFooterProps> = ({
52
44
  });
53
45
  }
54
46
 
47
+ buttons.push({
48
+ label: 'Confirm',
49
+ type: 'filled' as const,
50
+ onClick: onNext,
51
+ disabled: false,
52
+ loading: false,
53
+ });
54
+
55
55
  return buttons;
56
56
  }, [selectedIntegration, onBack, onNext]);
57
57
 
@@ -62,14 +62,14 @@ const CardFooter: React.FC<CardFooterProps> = ({
62
62
  return (
63
63
  <Spacer direction="horizontal" size={0} justifyContent="space-between">
64
64
  <FooterLinks fullWidth={fullWidth} />
65
- <Padded vertical="medium" horizontal="medium" fullHeight={false}>
65
+ <Padded vertical="none" horizontal="medium" fullHeight={false}>
66
66
  <Flex direction={FlexDirection.Horizontal} justify={FlexJustify.Right}>
67
67
  <Spacer direction="horizontal" size={10}>
68
68
  {buttons.map((button) => (
69
69
  <Button
70
70
  key={button.label}
71
71
  size="small"
72
- type={button.type}
72
+ variant={button.type}
73
73
  onClick={button.onClick}
74
74
  disabled={button.disabled}
75
75
  loading={button.loading}
@@ -29,7 +29,7 @@ const CardTitle: React.FC<CardTitleProps> = ({ selectedIntegration, onBack, guid
29
29
  gapSize={FlexGapSize.Small}
30
30
  justify={FlexJustify.Left}
31
31
  >
32
- {onBack && <Button type="ghost" onClick={onBack} icon="←" size="small" />}
32
+ {onBack && <Button variant="ghost" onClick={onBack} icon="←" size="small" />}
33
33
  <img
34
34
  src={`https://app.stackone.com/assets/logos/${selectedIntegration.provider}.png`}
35
35
  alt={selectedIntegration.provider}
@@ -39,11 +39,13 @@ const CardTitle: React.FC<CardTitleProps> = ({ selectedIntegration, onBack, guid
39
39
  {selectedIntegration.name}
40
40
  </Typography.Text>
41
41
  </Flex>
42
- <Typography.Link href={guide?.supportLink} target="_blank">
43
- <Button type="outline" size="medium">
44
- Connection guide
45
- </Button>
46
- </Typography.Link>
42
+ {guide?.supportLink && (
43
+ <Typography.LinkText href={guide?.supportLink} target="_blank">
44
+ <Button variant="outline" size="medium">
45
+ Connection guide
46
+ </Button>
47
+ </Typography.LinkText>
48
+ )}
47
49
  </Flex>
48
50
  );
49
51
  };
@@ -1,5 +1,6 @@
1
+ import { evaluate } from '@stackone/expressions';
1
2
  import { useQuery } from '@tanstack/react-query';
2
- import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
4
  import {
4
5
  connectAccount,
5
6
  getAccountData,
@@ -7,7 +8,7 @@ import {
7
8
  getHubData,
8
9
  updateAccount,
9
10
  } from '../queries';
10
- import { Integration } from '../types';
11
+ import { ConnectorConfigField, Integration } from '../types';
11
12
 
12
13
  const DUMMY_VALUE = 'totally-fake-value';
13
14
 
@@ -16,6 +17,13 @@ interface UseIntegrationPickerProps {
16
17
  baseUrl: string;
17
18
  accountId?: string;
18
19
  onSuccess?: () => void;
20
+ dashboardUrl?: string;
21
+ }
22
+
23
+ export enum EventType {
24
+ AccountConnected = 'AccountConnected',
25
+ CloseModal = 'CloseModal',
26
+ CloseOAuth2 = 'CloseOAuth2',
19
27
  }
20
28
 
21
29
  export const useIntegrationPicker = ({
@@ -23,9 +31,13 @@ export const useIntegrationPicker = ({
23
31
  baseUrl,
24
32
  accountId,
25
33
  onSuccess,
34
+ dashboardUrl,
26
35
  }: UseIntegrationPickerProps) => {
27
36
  const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
28
37
  const [formData, setFormData] = useState<Record<string, string>>({});
38
+ const connectWindow = useRef<Window | null>(null);
39
+ const checkStateTimeoutRef = useRef<number | null>(null);
40
+ const successTimeoutRef = useRef<number | null>(null);
29
41
  const [connectionState, setConnectionState] = useState<{
30
42
  loading: boolean;
31
43
  success: boolean;
@@ -38,7 +50,48 @@ export const useIntegrationPicker = ({
38
50
  success: false,
39
51
  });
40
52
 
41
- // Fetch account data for editing scenario
53
+ useEffect(() => {
54
+ return () => {
55
+ if (checkStateTimeoutRef.current !== null) {
56
+ clearTimeout(checkStateTimeoutRef.current);
57
+ }
58
+ if (successTimeoutRef.current !== null) {
59
+ clearTimeout(successTimeoutRef.current);
60
+ }
61
+ };
62
+ }, []);
63
+
64
+ const processMessageCallback = useCallback((event: MessageEvent) => {
65
+ if (event.data.type === EventType.AccountConnected) {
66
+ setConnectionState({ loading: false, success: true });
67
+ parent.postMessage(event.data, '*');
68
+ if (connectWindow.current) {
69
+ connectWindow.current.close();
70
+ connectWindow.current = null;
71
+ }
72
+ window.removeEventListener('message', processMessageCallback, false);
73
+ } else if (event.data.type === EventType.CloseOAuth2) {
74
+ if (event.data.error) {
75
+ setConnectionState({
76
+ loading: false,
77
+ success: false,
78
+ error: {
79
+ message: event.data.error,
80
+ provider_response: event.data.errorDescription || 'No description',
81
+ },
82
+ });
83
+ } else {
84
+ setConnectionState({ loading: false, success: false, error: undefined });
85
+ }
86
+
87
+ if (connectWindow.current) {
88
+ connectWindow.current.close();
89
+ connectWindow.current = null;
90
+ }
91
+ window.removeEventListener('message', processMessageCallback, false);
92
+ }
93
+ }, []);
94
+
42
95
  const {
43
96
  data: accountData,
44
97
  isLoading: isLoadingAccountData,
@@ -52,7 +105,6 @@ export const useIntegrationPicker = ({
52
105
  enabled: !!accountId,
53
106
  });
54
107
 
55
- // Fetch hub data (list of integrations)
56
108
  const {
57
109
  data: hubData,
58
110
  isLoading: isLoadingHubData,
@@ -60,17 +112,14 @@ export const useIntegrationPicker = ({
60
112
  } = useQuery({
61
113
  queryKey: ['hubData', accountData?.provider],
62
114
  queryFn: () => {
63
- // For account editing: fetch hub data with specific provider
64
115
  if (accountData?.provider) {
65
116
  return getHubData(token, baseUrl, accountData.provider);
66
117
  }
67
- // For new setup: fetch all integrations
68
118
  return getHubData(token, baseUrl);
69
119
  },
70
- enabled: !accountId || !!accountData, // Enable when no accountId OR when we have account data
120
+ enabled: !accountId || !!accountData,
71
121
  });
72
122
 
73
- // Auto-select integration when editing an account
74
123
  useEffect(() => {
75
124
  if (accountData && hubData) {
76
125
  const matchingIntegration = hubData.integrations.find(
@@ -80,7 +129,6 @@ export const useIntegrationPicker = ({
80
129
  }
81
130
  }, [accountData, hubData]);
82
131
 
83
- // Fetch connector configuration
84
132
  const {
85
133
  data: connectorData,
86
134
  isLoading: isLoadingConnectorData,
@@ -99,47 +147,100 @@ export const useIntegrationPicker = ({
99
147
  enabled: Boolean(selectedIntegration) || Boolean(accountData),
100
148
  });
101
149
 
102
- // Extract fields and guide from connector config
103
150
  const { fields, guide } = useMemo(() => {
104
151
  if (!connectorData || !selectedIntegration) {
105
- return { fields: [] };
152
+ const fields: ConnectorConfigField[] = [];
153
+ return { fields };
106
154
  }
107
155
 
108
156
  const authConfig =
109
- connectorData.authentication?.[selectedIntegration.authentication_config_key];
157
+ connectorData.config.authentication?.[selectedIntegration.authentication_config_key];
110
158
  const authConfigForEnvironment = authConfig?.[selectedIntegration.environment];
111
159
 
112
160
  const baseFields = authConfigForEnvironment?.fields || [];
113
161
 
114
- const fieldsWithPrefilledValues = baseFields.map((field) => {
115
- const setupValue = accountData?.setupInformation?.[field.key];
162
+ const fieldsWithPrefilledValues: ConnectorConfigField[] = baseFields
163
+ .map((field) => {
164
+ const setupValue = accountData?.setupInformation?.[field.key];
165
+
166
+ if (accountData && (field.secret || field.type === 'password')) {
167
+ return {
168
+ ...field,
169
+ key: field.key,
170
+ value: DUMMY_VALUE,
171
+ };
172
+ }
173
+
174
+ if (field.key === 'external-trigger-token') {
175
+ return {
176
+ ...field,
177
+ key: field.key,
178
+ value: hubData?.external_trigger_token,
179
+ };
180
+ }
181
+
182
+ const evaluationContext = {
183
+ ...formData,
184
+ ...accountData?.setupInformation,
185
+ external_trigger_token: hubData?.external_trigger_token,
186
+ hub_settings: connectorData.hub_settings,
187
+ };
188
+
189
+ if (field.condition) {
190
+ const evaluated = evaluate(field.condition, evaluationContext);
191
+
192
+ const shouldShow = evaluated != null && evaluated !== 'false';
193
+
194
+ if (!shouldShow) {
195
+ return;
196
+ }
197
+ }
198
+
199
+ if (!field.value) {
200
+ return {
201
+ ...field,
202
+ key: field.key,
203
+ };
204
+ }
205
+
206
+ const valueToEvaluate = setupValue !== undefined ? setupValue : field.value;
207
+ let evaluatedValue = evaluate(valueToEvaluate?.toString(), evaluationContext);
208
+
209
+ if (typeof evaluatedValue === 'object' && evaluatedValue !== null) {
210
+ evaluatedValue = JSON.stringify(evaluatedValue);
211
+ }
116
212
 
117
- if (accountData && (field.secret || field.type === 'password')) {
118
213
  return {
119
214
  ...field,
120
- value: DUMMY_VALUE,
215
+ key: field.key,
216
+ value: evaluatedValue as string | number | undefined,
121
217
  };
122
- }
123
-
124
- return {
125
- ...field,
126
- value: setupValue !== undefined ? setupValue : field.value,
127
- };
128
- });
218
+ })
219
+ .filter((value) => value != null);
129
220
 
130
221
  return {
131
222
  fields: fieldsWithPrefilledValues,
132
223
  guide: authConfigForEnvironment?.guide,
133
224
  };
134
- }, [connectorData, selectedIntegration, accountData]);
225
+ }, [connectorData, selectedIntegration, accountData, formData, hubData]);
226
+
227
+ const authConfig = useMemo(() => {
228
+ if (!connectorData || !selectedIntegration) {
229
+ return null;
230
+ }
231
+ return connectorData.config.authentication?.[
232
+ selectedIntegration.authentication_config_key
233
+ ]?.[selectedIntegration.environment];
234
+ }, [connectorData, selectedIntegration]);
135
235
 
136
236
  const handleConnect = useCallback(async () => {
137
- if (!selectedIntegration) return;
237
+ if (!selectedIntegration) {
238
+ return;
239
+ }
138
240
 
139
241
  setConnectionState({ loading: true, success: false });
140
242
 
141
243
  try {
142
- // Clean up dummy values for secret fields before submission
143
244
  const cleanedFormData = { ...formData };
144
245
  if (accountData) {
145
246
  fields.forEach((field) => {
@@ -152,6 +253,62 @@ export const useIntegrationPicker = ({
152
253
  });
153
254
  }
154
255
 
256
+ if (authConfig?.type === 'oauth2') {
257
+ window.addEventListener('message', processMessageCallback, false);
258
+ const callbackEmbeddedAccountsUrl = encodeURIComponent(
259
+ `${dashboardUrl}/embedded/accounts/callback`,
260
+ );
261
+ let windowUrl = `${baseUrl}/connect/oauth2/${selectedIntegration.provider}?redirect_uri=${callbackEmbeddedAccountsUrl}&token=${token}`;
262
+
263
+ Object.keys(cleanedFormData).forEach((key) => {
264
+ windowUrl += `&${key}=${encodeURIComponent(cleanedFormData[key])}`;
265
+ });
266
+
267
+ const width = 1024;
268
+ const height = 800;
269
+ const screenX =
270
+ typeof window.screenX != 'undefined' ? window.screenX : window.screenLeft;
271
+ const screenY =
272
+ typeof window.screenY != 'undefined' ? window.screenY : window.screenTop;
273
+ const outerWidth =
274
+ typeof window.outerWidth != 'undefined'
275
+ ? window.outerWidth
276
+ : document.body.clientWidth;
277
+ const outerHeight =
278
+ typeof window.outerHeight != 'undefined'
279
+ ? window.outerHeight
280
+ : document.body.clientHeight - 22;
281
+ const left = screenX + (outerWidth - width) / 2;
282
+ const top = screenY + (outerHeight - height) / 2.5;
283
+ const features =
284
+ 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top;
285
+
286
+ connectWindow.current = window.open(windowUrl, 'Connect Account', features);
287
+
288
+ if (connectWindow.current) {
289
+ connectWindow.current.focus();
290
+ const checkWindowState = () => {
291
+ if (connectWindow.current?.closed) {
292
+ setConnectionState({ loading: false, success: false });
293
+ window.removeEventListener('message', processMessageCallback, false);
294
+ connectWindow.current = null;
295
+ if (checkStateTimeoutRef.current !== null) {
296
+ clearTimeout(checkStateTimeoutRef.current);
297
+ checkStateTimeoutRef.current = null;
298
+ }
299
+ } else if (connectWindow.current) {
300
+ checkStateTimeoutRef.current = window.setTimeout(
301
+ checkWindowState,
302
+ 1000,
303
+ );
304
+ }
305
+ };
306
+ checkStateTimeoutRef.current = window.setTimeout(checkWindowState, 1000);
307
+ }
308
+
309
+ return;
310
+ }
311
+
155
312
  if (accountId) {
156
313
  await updateAccount(
157
314
  baseUrl,
@@ -165,8 +322,12 @@ export const useIntegrationPicker = ({
165
322
  }
166
323
 
167
324
  setConnectionState({ loading: false, success: true });
168
- setTimeout(() => {
325
+ if (successTimeoutRef.current !== null) {
326
+ clearTimeout(successTimeoutRef.current);
327
+ }
328
+ successTimeoutRef.current = window.setTimeout(() => {
169
329
  onSuccess?.();
330
+ successTimeoutRef.current = null;
170
331
  }, 2000);
171
332
  } catch (error) {
172
333
  const parsedError = JSON.parse((error as Error).message) as {
@@ -188,7 +349,19 @@ export const useIntegrationPicker = ({
188
349
  },
189
350
  });
190
351
  }
191
- }, [baseUrl, token, selectedIntegration, formData, onSuccess, accountData, fields, accountId]);
352
+ }, [
353
+ baseUrl,
354
+ dashboardUrl,
355
+ token,
356
+ selectedIntegration,
357
+ formData,
358
+ onSuccess,
359
+ accountData,
360
+ fields,
361
+ accountId,
362
+ authConfig,
363
+ processMessageCallback,
364
+ ]);
192
365
 
193
366
  const isLoading = isLoadingHubData || isLoadingConnectorData || isLoadingAccountData;
194
367
  const hasError = !!(errorHubData || errorConnectorData || errorAccountData);
@@ -1,5 +1,5 @@
1
1
  import { getRequest, patchRequest, postRequest } from '../../shared/httpClient';
2
- import { AccountData, ConnectorConfig, HubData } from './types';
2
+ import { AccountData, ConnectorConfig, HubConnectorConfig, HubData } from './types';
3
3
 
4
4
  export const getHubData = async (token: string, baseUrl: string, provider?: string) => {
5
5
  const headers: Record<string, string> = {
@@ -19,7 +19,7 @@ export const getHubData = async (token: string, baseUrl: string, provider?: stri
19
19
  };
20
20
 
21
21
  export const getConnectorConfig = async (baseUrl: string, token: string, connectorKey: string) => {
22
- return await getRequest<ConnectorConfig>({
22
+ return await getRequest<HubConnectorConfig>({
23
23
  url: `${baseUrl}/hub/connectors/${connectorKey}`,
24
24
  headers: {
25
25
  'Content-Type': 'application/json',
@@ -10,6 +10,7 @@ export interface Integration {
10
10
 
11
11
  export interface HubData {
12
12
  integrations: Array<Integration>;
13
+ external_trigger_token?: string;
13
14
  }
14
15
 
15
16
  export interface ConnectorConfigField {
@@ -48,11 +49,20 @@ export interface ConnectorConfig {
48
49
  supportLink?: string;
49
50
  description: string;
50
51
  };
52
+ type: 'oauth2' | 'oidc' | 'custom';
51
53
  };
52
54
  };
53
55
  };
54
56
  }
55
57
 
58
+ export interface HubConnectorConfig {
59
+ config: ConnectorConfig;
60
+ hub_settings: {
61
+ configured_webhook_events: Record<string, Set<string>>;
62
+ project_settings: Record<string, string | object>;
63
+ };
64
+ }
65
+
56
66
  export interface AccountData {
57
67
  account_id: string;
58
68
  provider: string;
@@ -6,11 +6,9 @@ import {
6
6
  FlexGapSize,
7
7
  FlexJustify,
8
8
  Typography,
9
- useTheme,
10
9
  } from '@stackone/malachite';
11
10
 
12
11
  const ErrorContainer: React.FC = () => {
13
- const { colors } = useTheme();
14
12
  return (
15
13
  <Flex
16
14
  justify={FlexJustify.Center}
@@ -19,7 +17,8 @@ const ErrorContainer: React.FC = () => {
19
17
  gapSize={FlexGapSize.Small}
20
18
  fullHeight
21
19
  >
22
- <CustomIcons.RejectIcon style={{ color: colors.redForeground }} />
20
+ {/* TODO: fix */}
21
+ <CustomIcons.RejectIcon style={{ color: 'red' }} />
23
22
  <Typography.Text fontWeight="bold" size="large">
24
23
  Error
25
24
  </Typography.Text>
@@ -6,28 +6,35 @@ import {
6
6
  FlexGapSize,
7
7
  FlexJustify,
8
8
  Typography,
9
+ getCurrentTheme,
9
10
  } from '@stackone/malachite';
10
11
 
11
12
  interface SuccessProps {
12
13
  integrationName: string;
13
14
  }
14
15
 
15
- const Success: React.FC<SuccessProps> = ({ integrationName }) => (
16
- <Flex
17
- justify={FlexJustify.Center}
18
- align={FlexAlign.Center}
19
- direction={FlexDirection.Vertical}
20
- gapSize={FlexGapSize.Small}
21
- fullHeight
22
- >
23
- <CustomIcons.CheckCircleFilledIcon />
24
- <Typography.Text fontWeight="bold" size="large">
25
- Connection Successful
26
- </Typography.Text>
27
- <Typography.SecondaryText>
28
- Account successfully connected to {integrationName}
29
- </Typography.SecondaryText>
30
- </Flex>
31
- );
16
+ const Success: React.FC<SuccessProps> = ({ integrationName }) => {
17
+ const theme = getCurrentTheme();
18
+ return (
19
+ <Flex
20
+ justify={FlexJustify.Center}
21
+ align={FlexAlign.Center}
22
+ direction={FlexDirection.Vertical}
23
+ gapSize={FlexGapSize.Small}
24
+ fullHeight
25
+ >
26
+ <CustomIcons.CheckCircleFilled
27
+ size={16}
28
+ style={{ color: theme.colors.success.foreground }}
29
+ />
30
+ <Typography.Text fontWeight="bold" size="large">
31
+ Connection Successful
32
+ </Typography.Text>
33
+ <Typography.SecondaryText>
34
+ Account successfully connected to {integrationName}
35
+ </Typography.SecondaryText>
36
+ </Flex>
37
+ );
38
+ };
32
39
 
33
40
  export default Success;
@@ -0,0 +1,26 @@
1
+ import { createContext, useMemo } from 'react';
2
+ import { FeatureFlag } from '../types/featureFlags';
3
+
4
+ export const FeatureFlagContext = createContext<{ featureFlags: FeatureFlag[] }>({
5
+ featureFlags: [],
6
+ });
7
+
8
+ export const FeatureFlagProvider = ({
9
+ featureFlags,
10
+ children,
11
+ }: {
12
+ featureFlags: FeatureFlag[];
13
+ children: React.ReactNode;
14
+ }) => {
15
+ const memoizedContextValue = useMemo(
16
+ () => ({
17
+ featureFlags,
18
+ }),
19
+ [featureFlags],
20
+ );
21
+ return (
22
+ <FeatureFlagContext.Provider value={memoizedContextValue}>
23
+ {children}
24
+ </FeatureFlagContext.Provider>
25
+ );
26
+ };
@@ -0,0 +1,24 @@
1
+ import { useContext } from 'react';
2
+ import { FeatureFlagContext } from '../contexts/featureFlagContext';
3
+ import { FeatureFlag } from '../types/featureFlags';
4
+
5
+ const isFeatureEnabled = ({
6
+ featureFlags,
7
+ featureFlag,
8
+ }: { featureFlags: FeatureFlag[]; featureFlag: FeatureFlag }): boolean => {
9
+ return featureFlags.includes(featureFlag);
10
+ };
11
+
12
+ export const useFeatureFlags = (featureFlag: FeatureFlag): boolean => {
13
+ const { featureFlags } = useContext(FeatureFlagContext);
14
+ if (!featureFlags) {
15
+ throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
16
+ }
17
+
18
+ return isFeatureEnabled({
19
+ featureFlags,
20
+ featureFlag,
21
+ });
22
+ };
23
+
24
+ export default useFeatureFlags;
@@ -0,0 +1,12 @@
1
+ import { getRequest } from './httpClient';
2
+ import { FeatureFlag } from './types/featureFlags';
3
+
4
+ export const getSettings = async (baseUrl: string, token: string) => {
5
+ return await getRequest<{ enabled_features: FeatureFlag[] }>({
6
+ url: `${baseUrl}/hub/settings`,
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ 'x-hub-session-token': token,
10
+ },
11
+ });
12
+ };
@@ -0,0 +1 @@
1
+ export type FeatureFlag = 'hub_link_account_release';
@@ -1 +0,0 @@
1
- Coming soon
@@ -1,37 +0,0 @@
1
- {
2
- "name": "@stackone/malachite",
3
- "version": "0.0.0",
4
- "type": "module",
5
- "module": "dist/index.js",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "files": [
9
- "dist",
10
- "README.md",
11
- "LICENSE"
12
- ],
13
- "exports": {
14
- ".": {
15
- "types": "./dist/index.d.ts",
16
- "require": "./dist/index.js",
17
- "default": "./dist/index.js"
18
- },
19
- "./style.css": "./dist/style.css"
20
- },
21
- "scripts": {
22
- "dev": "vite",
23
- "build": "tsc -b ./tsconfig.app.json && vite build",
24
- "build:watch": "vite build --watch",
25
- "build:hot": "concurrently \"tsc -b ./tsconfig.app.json --watch\" \"vite build --watch\"",
26
- "lint:check": "biome lint",
27
- "lint:fix": "biome lint --fix",
28
- "preview": "vite preview",
29
- "storybook": "storybook dev -p 6006",
30
- "build-storybook": "storybook build"
31
- },
32
- "peerDependencies": {
33
- "react": "^18.0.0",
34
- "react-dom": "^18.1.0"
35
- },
36
- "yalcSig": "b3621bd0c8195b6fd4451f514c72a618"
37
- }
@@ -1 +0,0 @@
1
- b3621bd0c8195b6fd4451f514c72a618
package/yalc.lock DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "version": "v1",
3
- "packages": {
4
- "@stackone/malachite": {
5
- "signature": "b3621bd0c8195b6fd4451f514c72a618",
6
- "file": true
7
- }
8
- }
9
- }