diff --git a/src/modules/integration-picker/IntegrationPicker.tsx b/src/modules/integration-picker/IntegrationPicker.tsx index 0f4a945..157b525 100644 --- a/src/modules/integration-picker/IntegrationPicker.tsx +++ b/src/modules/integration-picker/IntegrationPicker.tsx @@ -41,6 +41,7 @@ export const IntegrationPicker: React.FC = ({ connectorData, selectedIntegration, fields, + notices, guide, // State @@ -152,6 +153,7 @@ export const IntegrationPicker: React.FC = ({ connectorData={connectorData?.config ?? null} hubData={hubData ?? null} fields={fields} + notices={notices} errorHubData={(errorHubData as Error) ?? null} errorConnectorData={(errorConnectorData as Error) ?? null} onSelect={setSelectedIntegration} diff --git a/src/modules/integration-picker/components/IntegrationFields.tsx b/src/modules/integration-picker/components/IntegrationFields.tsx index d960ad4..892b727 100644 --- a/src/modules/integration-picker/components/IntegrationFields.tsx +++ b/src/modules/integration-picker/components/IntegrationFields.tsx @@ -15,11 +15,12 @@ import { TextArea, Typography, } from '@stackone/malachite'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { FieldErrors, UseFormSetValue } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import useDeepCompareEffect from 'use-deep-compare-effect'; -import { ConnectorConfigField } from '../types'; +import { AuthenticationNotice, ConnectorConfigField } from '../types'; +import { partitionNotices } from '../utils/partitionNotices'; import { formatSecretPlaceholder, isSecretPlaceholder } from '../utils/secretPlaceholder'; import { createFormSchema } from '../utils/zodSchema'; @@ -215,6 +216,7 @@ const ErrorBlock = ({ error }: { error?: { message: string; provider_response: s }; interface IntegrationFieldsProps { fields: Array; + notices?: Array; error?: { message: string; provider_response: string; @@ -229,7 +231,9 @@ interface IntegrationFieldsProps { const NoFieldsView: React.FC<{ integrationName: string; error?: { message: string; provider_response: string }; -}> = ({ integrationName, error }) => { + notices?: AuthenticationNotice[]; +}> = ({ integrationName, error, notices = [] }) => { + const topNotices = notices.filter((n) => !n.position || n.position === 'top'); return ( {error && ( @@ -241,6 +245,9 @@ const NoFieldsView: React.FC<{ )} + {topNotices.map((n) => ( + + ))} = ({ fields, + notices = [], onChange, error, onValidationChange, @@ -272,6 +280,11 @@ export const IntegrationForm: React.FC = ({ editingSecrets, setEditingSecrets, }) => { + const displayedFields = fields.filter((f) => f.display !== false); + const fieldKeys = displayedFields.map((f) => + typeof f.key === 'object' ? JSON.stringify(f.key) : String(f.key), + ); + const { noticesBefore, noticesAfter } = partitionNotices(notices, fieldKeys); const schema = useMemo(() => createFormSchema(fields), [fields]); const defaultValues = useMemo(() => { @@ -308,8 +321,8 @@ export const IntegrationForm: React.FC = ({ onValidationChange?.(isValid); }, [isValid, onValidationChange]); - if (fields.length === 0) { - return ; + if (displayedFields.length === 0) { + return ; } return ( @@ -321,25 +334,51 @@ export const IntegrationForm: React.FC = ({ )}
- {fields - .filter((field) => field.display !== false) - .map((field) => { - const key = - typeof field.key === 'object' - ? JSON.stringify(field.key) - : String(field.key); - return ( -
- { + const key = + typeof field.key === 'object' + ? JSON.stringify(field.key) + : String(field.key); + const hasNotices = + noticesBefore(key).length > 0 || noticesAfter(key).length > 0; + return ( +
+ {noticesBefore(key).map((n) => ( + + ))} + + {noticesAfter(key).map((n) => ( + -
- ); - })} + ))} +
+ ); + })}
diff --git a/src/modules/integration-picker/components/IntegrationPickerContent.tsx b/src/modules/integration-picker/components/IntegrationPickerContent.tsx index a7772eb..91fe11d 100644 --- a/src/modules/integration-picker/components/IntegrationPickerContent.tsx +++ b/src/modules/integration-picker/components/IntegrationPickerContent.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { ConnectorConfig, ConnectorConfigField, HubData, Integration } from '../types'; +import { + AuthenticationNotice, + ConnectorConfig, + ConnectorConfigField, + HubData, + Integration, +} from '../types'; import { ErrorView } from './views/ErrorView'; import { IntegrationFormView } from './views/IntegrationFormView'; import { IntegrationListView } from './views/IntegrationListView'; @@ -24,6 +30,7 @@ interface IntegrationPickerContentProps { connectorData: ConnectorConfig | null; hubData: HubData | null; fields: ConnectorConfigField[]; + notices?: AuthenticationNotice[]; selectedCategory: string | null; search: string; @@ -48,6 +55,7 @@ export const IntegrationPickerContent: React.FC = connectorData, hubData, fields, + notices, selectedCategory, search, errorHubData, @@ -113,6 +121,7 @@ export const IntegrationPickerContent: React.FC = return ( = ({ fields, + notices, error, onChange, onValidationChange, @@ -27,6 +29,7 @@ export const IntegrationFormView: React.FC = ({ return ( { + const { fields, guide, notices } = useMemo(() => { if (!connectorData || !selectedIntegration) { const fields: ConnectorConfigField[] = []; - return { fields }; + const notices: AuthenticationNotice[] = []; + return { fields, notices }; } if (isFalconConnectorConfig(connectorData.config)) { - const fieldsWithPrefilledValues: ConnectorConfigField[] = - connectorData.config.configFields - .map((field) => { - const setupValue = accountData?.setupInformation?.[field.key]; + const fieldsWithPrefilledValues: ConnectorConfigField[] = ( + connectorData.config.configFields ?? [] + ) + .map((field) => { + const setupValue = accountData?.setupInformation?.[field.key]; - if (field.key === 'external-trigger-token') { - return { - ...field, - key: field.key, - value: hubData?.external_trigger_token, - }; - } + if (field.key === 'external-trigger-token') { + return { + ...field, + key: field.key, + value: hubData?.external_trigger_token, + }; + } - if (accountData && (field.secret !== false || field.type === 'password')) { - const secretValue = accountData.secrets?.[field.key]; - if (secretValue) { - return { - ...field, - key: field.key, - value: secretValue, - }; - } + if (accountData && (field.secret !== false || field.type === 'password')) { + const secretValue = accountData.secrets?.[field.key]; + if (secretValue) { return { ...field, key: field.key, - value: '', + value: secretValue, }; } - - const evaluationContext = { - ...formData, - ...accountData?.setupInformation, - external_trigger_token: hubData?.external_trigger_token, - webhooks_url: hubData?.webhooks_url, - events_encoded_context: hubData?.events_encoded_context, - hub_settings: connectorData.hub_settings, + return { + ...field, + key: field.key, + value: '', }; + } - if (field.condition) { - const evaluated = evaluate(field.condition, evaluationContext); + const evaluationContext = { + ...formData, + ...accountData?.setupInformation, + external_trigger_token: hubData?.external_trigger_token, + webhooks_url: hubData?.webhooks_url, + events_encoded_context: hubData?.events_encoded_context, + hub_settings: connectorData.hub_settings, + }; - const shouldShow = evaluated != null && evaluated !== 'false'; + if (field.condition) { + const evaluated = evaluate(field.condition, evaluationContext); - if (!shouldShow) { - return; - } - } + const shouldShow = evaluated != null && evaluated !== 'false'; - const valueToEvaluate = setupValue !== undefined ? setupValue : field.value; - - if (!valueToEvaluate) { - return { - ...field, - key: field.key, - }; + if (!shouldShow) { + return; } - let evaluatedValue = evaluate( - valueToEvaluate?.toString(), - evaluationContext, - ); + } - if (typeof evaluatedValue === 'object' && evaluatedValue !== null) { - evaluatedValue = JSON.stringify(evaluatedValue); - } + const valueToEvaluate = setupValue !== undefined ? setupValue : field.value; + if (!valueToEvaluate) { return { ...field, key: field.key, - value: evaluatedValue as string | number | undefined, }; - }) - .filter((value) => value != null); + } + let evaluatedValue = evaluate(valueToEvaluate?.toString(), evaluationContext); + + if (typeof evaluatedValue === 'object' && evaluatedValue !== null) { + evaluatedValue = JSON.stringify(evaluatedValue); + } + + return { + ...field, + key: field.key, + value: evaluatedValue as string | number | undefined, + }; + }) + .filter((value) => value != null); return { fields: fieldsWithPrefilledValues, + notices: connectorData.config.configNotices ?? [], guide: { supportLink: connectorData.config.support?.link, description: connectorData.config.support?.description ?? '', @@ -507,6 +508,7 @@ export const useIntegrationPicker = ({ return { fields: fieldsWithPrefilledValues, + notices: [], guide: authConfigForEnvironment?.guide, }; }, [connectorData, selectedIntegration, accountData, formData, hubData]); @@ -901,6 +903,7 @@ export const useIntegrationPicker = ({ connectorData, selectedIntegration, fields, + notices, guide, // State diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index 950809c..bf7543c 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -65,12 +65,38 @@ export interface LegacyConnectorConfig { }; } +type AuthenticationNoticeTop = { + key: string; + type: 'warning' | 'info'; + description: string; + position?: 'top'; + anchor?: string; +}; + +type AuthenticationNoticeBottom = { + key: string; + type: 'warning' | 'info'; + description: string; + position: 'bottom'; +}; + +export type AuthenticationNotice = AuthenticationNoticeTop | AuthenticationNoticeBottom; + +export function hasAnchor( + notice: AuthenticationNotice, +): notice is AuthenticationNoticeTop & { anchor: string } { + return ( + 'anchor' in notice && typeof notice.anchor === 'string' && notice.anchor.trim().length > 0 + ); +} + export interface FalconConnectorConfig { key: string; name: string; type: 'oauth2' | 'custom'; grantType?: 'authorization_code' | 'client_credentials'; - configFields: Array; + configFields?: Array; + configNotices?: Array; assets?: { icon: string; }; @@ -100,7 +126,7 @@ export function isLegacyConnectorConfig(config: ConnectorConfig): config is Lega } export function isFalconConnectorConfig(config: ConnectorConfig): config is FalconConnectorConfig { - return 'configFields' in config && !('authentication' in config); + return ('configFields' in config || 'configNotices' in config) && !('authentication' in config); } export interface AccountData { diff --git a/src/modules/integration-picker/utils/partitionNotices.ts b/src/modules/integration-picker/utils/partitionNotices.ts new file mode 100644 index 0000000..9c0ed6a --- /dev/null +++ b/src/modules/integration-picker/utils/partitionNotices.ts @@ -0,0 +1,21 @@ +import { hasAnchor } from '../types'; +import type { AuthenticationNotice } from '../types'; + +export function partitionNotices(notices: AuthenticationNotice[], fieldKeys: string[] = []) { + const firstKey = fieldKeys[0]; + const lastKey = fieldKeys[fieldKeys.length - 1]; + + const noticesBefore = (fieldKey: string) => + notices.filter((n) => { + if (hasAnchor(n)) return n.anchor === fieldKey; + return (!n.position || n.position === 'top') && fieldKey === firstKey; + }); + + const noticesAfter = (fieldKey: string) => + notices.filter((n) => { + if (hasAnchor(n)) return false; + return n.position === 'bottom' && fieldKey === lastKey; + }); + + return { noticesBefore, noticesAfter }; +}