diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 19da0d36d..5dd9c9b28 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -19,6 +19,7 @@ import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { isDefined } from '~/utils/isDefined'; +import { useCleanRecoilState } from '~/hooks/useCleanRecoilState'; // TODO: break down into smaller functions and / or hooks // - moved usePageChangeEffectNavigateLocation into dedicated hook @@ -35,6 +36,8 @@ export const PageChangeEffect = () => { const pageChangeEffectNavigateLocation = usePageChangeEffectNavigateLocation(); + const { cleanRecoilState } = useCleanRecoilState(); + const eventTracker = useEventTracker(); const { addToCommandMenu, setToInitialCommandMenu } = useCommandMenu(); @@ -43,6 +46,10 @@ export const PageChangeEffect = () => { activityObjectNameSingular: CoreObjectNameSingular.Task, }); + useEffect(() => { + cleanRecoilState(); + }, [cleanRecoilState]); + useEffect(() => { if (!previousLocation || previousLocation !== location.pathname) { setPreviousLocation(location.pathname); diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 327e940ac..ad989ea4f 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -6,6 +6,7 @@ import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql'; import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; +import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths'; jest.mock('@/onboarding/hooks/useOnboardingStatus'); const setupMockOnboardingStatus = ( @@ -296,7 +297,7 @@ describe('usePageChangeEffectNavigateLocation', () => { SubscriptionStatus.Trialing, ]; expect(testCases.length).toEqual( - Object.keys(AppPath).length * + (Object.keys(AppPath).length - UNTESTED_APP_PATHS.length) * (Object.keys(OnboardingStatus).length + (Object.keys(SubscriptionStatus).length - untestedSubscriptionStatus.length)), diff --git a/packages/twenty-front/src/hooks/useCleanRecoilState.ts b/packages/twenty-front/src/hooks/useCleanRecoilState.ts new file mode 100644 index 000000000..3f0a41df4 --- /dev/null +++ b/packages/twenty-front/src/hooks/useCleanRecoilState.ts @@ -0,0 +1,26 @@ +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { SettingsPath } from '@/types/SettingsPath'; +import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState'; +import { useRecoilValue, useResetRecoilState } from 'recoil'; +import { AppPath } from '@/types/AppPath'; +import { isDefined } from '~/utils/isDefined'; + +export const useCleanRecoilState = () => { + const isMatchingLocation = useIsMatchingLocation(); + const resetApiKeyToken = useResetRecoilState(apiKeyTokenState); + const apiKeyToken = useRecoilValue(apiKeyTokenState); + const cleanRecoilState = () => { + if ( + !isMatchingLocation( + `${AppPath.Settings}/${AppPath.Developers}/${SettingsPath.DevelopersApiKeyDetail}`, + ) && + isDefined(apiKeyToken) + ) { + resetApiKeyToken(); + } + }; + + return { + cleanRecoilState, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx b/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx new file mode 100644 index 000000000..12c43dfb1 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { useDebouncedCallback } from 'use-debounce'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { isDefined } from '~/utils/isDefined'; + +const StyledComboInputContainer = styled.div` + display: flex; + flex-direction: row; + > * + * { + margin-left: ${({ theme }) => theme.spacing(4)}; + } +`; + +type ApiKeyNameInputProps = { + apiKeyName: string; + apiKeyId: string; + disabled: boolean; + onNameUpdate?: (name: string) => void; +}; + +export const ApiKeyNameInput = ({ + apiKeyName, + apiKeyId, + disabled, + onNameUpdate, +}: ApiKeyNameInputProps) => { + const { updateOneRecord: updateApiKey } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.ApiKey, + }); + + // TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedUpdate = useCallback( + useDebouncedCallback(async (name: string) => { + if (isDefined(onNameUpdate)) { + onNameUpdate(apiKeyName); + } + if (!apiKeyName) { + return; + } + await updateApiKey({ + idToUpdate: apiKeyId, + updateOneRecordInput: { name }, + }); + }, 500), + [updateApiKey, onNameUpdate], + ); + + useEffect(() => { + debouncedUpdate(apiKeyName); + return debouncedUpdate.cancel; + }, [debouncedUpdate, apiKeyName]); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useGeneratedApiKeys.test.ts b/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useGeneratedApiKeys.test.ts deleted file mode 100644 index b758a903d..000000000 --- a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useGeneratedApiKeys.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, RecoilState } from 'recoil'; - -import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState'; - -import { useGeneratedApiKeys } from '../useGeneratedApiKeys'; - -describe('useGeneratedApiKeys', () => { - test('should set generatedApiKeyFamilyState correctly', () => { - const { result } = renderHook(() => useGeneratedApiKeys(), { - wrapper: RecoilRoot, - }); - - const apiKeyId = 'someId'; - const apiKey = 'someKey'; - - act(() => { - result.current(apiKeyId, apiKey); - }); - - const recoilState: RecoilState = - generatedApiKeyFamilyState(apiKeyId); - - const stateValue = recoilState.key; - expect(stateValue).toContain(apiKeyId); - }); -}); diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/useGeneratedApiKeys.ts b/packages/twenty-front/src/modules/settings/developers/hooks/useGeneratedApiKeys.ts deleted file mode 100644 index 42dbbe0d4..000000000 --- a/packages/twenty-front/src/modules/settings/developers/hooks/useGeneratedApiKeys.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState'; - -export const useGeneratedApiKeys = () => { - return useRecoilCallback( - ({ set }) => - (apiKeyId: string, apiKey: string | null) => { - set(generatedApiKeyFamilyState(apiKeyId), apiKey); - }, - [], - ); -}; diff --git a/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyFamilyState.ts b/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyFamilyState.ts deleted file mode 100644 index f12c24e7d..000000000 --- a/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyFamilyState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; - -export const generatedApiKeyFamilyState = createFamilyState< - string | null | undefined, - string ->({ - key: 'generatedApiKeyFamilyState', - defaultValue: null, -}); diff --git a/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyTokenState.ts b/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyTokenState.ts new file mode 100644 index 000000000..6129e451c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/states/generatedApiKeyTokenState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const apiKeyTokenState = createState({ + key: 'apiKeyTokenState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index ca6f77009..5aab0b7e2 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -21,8 +21,10 @@ export enum AppPath { RecordIndexPage = '/objects/:objectNamePlural', RecordShowPage = '/object/:objectNameSingular/:objectRecordId', - SettingsCatchAll = `/settings/*`, - DevelopersCatchAll = `/developers/*`, + Settings = `settings`, + SettingsCatchAll = `/${Settings}/*`, + Developers = `developers`, + DevelopersCatchAll = `/${Developers}/*`, // Impersonate Impersonate = '/impersonate/:userId', diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx index 270ab80b1..ee0a46590 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx +++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx @@ -9,6 +9,7 @@ import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefau import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths'; jest.mock('@/onboarding/hooks/useOnboardingStatus'); const setupMockOnboardingStatus = ( @@ -331,7 +332,7 @@ describe('useShowAuthModal', () => { SubscriptionStatus.Trialing, ]; expect(testCases.length).toEqual( - Object.keys(AppPath).length * + (Object.keys(AppPath).length - UNTESTED_APP_PATHS.length) * (Object.keys(OnboardingStatus).length + (Object.keys(SubscriptionStatus).length - untestedSubscriptionStatus.length)), diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx index 2f3987e81..fd83b5ee6 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; import { DateTime } from 'luxon'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useRecoilState } from 'recoil'; import { H2Title, IconRepeat, IconSettings, IconTrash } from 'twenty-ui'; @@ -13,8 +13,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; -import { useGeneratedApiKeys } from '@/settings/developers/hooks/useGeneratedApiKeys'; -import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState'; +import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput'; import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date'; import { formatExpiration } from '@/settings/developers/utils/format-expiration'; @@ -25,7 +24,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer' import { Section } from '@/ui/layout/section/components/Section'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { useGenerateApiKeyTokenMutation } from '~/generated/graphql'; -import { isDefined } from '~/utils/isDefined'; +import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState'; const StyledInfo = styled.span` color: ${({ theme }) => theme.font.color.light}; @@ -49,10 +48,7 @@ export const SettingsDevelopersApiKeyDetail = () => { const navigate = useNavigate(); const { apiKeyId = '' } = useParams(); - const setGeneratedApi = useGeneratedApiKeys(); - const [generatedApiKey] = useRecoilState( - generatedApiKeyFamilyState(apiKeyId), - ); + const [apiKeyToken, setApiKeyToken] = useRecoilState(apiKeyTokenState); const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation(); const { createOneRecord: createOneApiKey } = useCreateOneRecord({ objectNameSingular: CoreObjectNameSingular.ApiKey, @@ -61,9 +57,14 @@ export const SettingsDevelopersApiKeyDetail = () => { objectNameSingular: CoreObjectNameSingular.ApiKey, }); - const { record: apiKeyData } = useFindOneRecord({ + const [apiKeyName, setApiKeyName] = useState(''); + + const { record: apiKeyData, loading } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.ApiKey, objectRecordId: apiKeyId, + onCompleted: (record) => { + setApiKeyName(record.name); + }, }); const deleteIntegration = async (redirect = true) => { @@ -111,20 +112,12 @@ export const SettingsDevelopersApiKeyDetail = () => { await deleteIntegration(false); if (isNonEmptyString(apiKey?.token)) { - setGeneratedApi(apiKey.id, apiKey.token); + setApiKeyToken(apiKey.token); navigate(`/settings/developers/api-keys/${apiKey.id}`); } } }; - useEffect(() => { - if (isDefined(apiKeyData)) { - return () => { - setGeneratedApi(apiKeyId, null); - }; - } - }); - return ( <> {apiKeyData?.name && ( @@ -134,18 +127,18 @@ export const SettingsDevelopersApiKeyDetail = () => {
- {generatedApiKey ? ( + {apiKeyToken ? ( <> - + {formatExpiration(apiKeyData?.expiresAt || '', true, false)} @@ -175,9 +168,25 @@ export const SettingsDevelopersApiKeyDetail = () => {
+ +
+
+ diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index 7199d871a..15b5a83c9 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -9,7 +9,6 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { EXPIRATION_DATES } from '@/settings/developers/constants/ExpirationDates'; -import { useGeneratedApiKeys } from '@/settings/developers/hooks/useGeneratedApiKeys'; import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { Select } from '@/ui/input/components/Select'; import { TextInput } from '@/ui/input/components/TextInput'; @@ -19,11 +18,13 @@ import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { useGenerateApiKeyTokenMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; import { Key } from 'ts-key-enum'; +import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState'; +import { useSetRecoilState } from 'recoil'; export const SettingsDevelopersApiKeysNew = () => { const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation(); const navigate = useNavigate(); - const setGeneratedApi = useGeneratedApiKeys(); + const setApiKeyToken = useSetRecoilState(apiKeyTokenState); const [formValues, setFormValues] = useState<{ name: string; expirationDate: number | null; @@ -57,7 +58,7 @@ export const SettingsDevelopersApiKeysNew = () => { }, }); if (isDefined(tokenData.data?.generateApiKeyToken)) { - setGeneratedApi(newApiKey.id, tokenData.data.generateApiKeyToken.token); + setApiKeyToken(tokenData.data.generateApiKeyToken.token); navigate(`/settings/developers/api-keys/${newApiKey.id}`); } }; diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts new file mode 100644 index 000000000..dd39b62d8 --- /dev/null +++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts @@ -0,0 +1,3 @@ +import { AppPath } from '@/types/AppPath'; + +export const UNTESTED_APP_PATHS = [AppPath.Settings, AppPath.Developers];