2060 create a new api key (#2206)

* Add folder for api settings

* Init create api key page

* Update create api key page

* Implement api call to create apiKey

* Add create api key mutation

* Get id when creating apiKey

* Display created Api Key

* Add delete api key button

* Remove button from InputText

* Update stuff

* Add test for ApiDetail

* Fix type

* Use recoil instead of router state

* Remane route paths

* Remove online return

* Move and test date util

* Remove useless Component

* Rename ApiKeys paths

* Rename ApiKeys files

* Add input text info testing

* Rename hooks to webhooks

* Remove console error

* Add tests to reach minimum coverage
This commit is contained in:
martmull
2023-10-24 16:14:54 +02:00
committed by GitHub
parent b6e8fabbb1
commit d61511262e
55 changed files with 919 additions and 133 deletions

View File

@ -21,6 +21,9 @@ import { SettingsNewObject } from '~/pages/settings/data-model/SettingsNewObject
import { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail';
import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit';
import { SettingsObjects } from '~/pages/settings/data-model/SettingsObjects';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
import { SettingsExperience } from '~/pages/settings/SettingsExperience';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
@ -31,7 +34,6 @@ import { getPageTitleFromPath } from '~/utils/title-utils';
import { ObjectTablePage } from './modules/metadata/components/ObjectTablePage';
import { SettingsObjectNewFieldStep1 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1';
import { SettingsObjectNewFieldStep2 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2';
import { SettingsApis } from './pages/settings/SettingsApis';
export const App = () => {
const { pathname } = useLocation();
@ -97,7 +99,25 @@ export const App = () => {
path={SettingsPath.NewObject}
element={<SettingsNewObject />}
/>
<Route path={SettingsPath.Apis} element={<SettingsApis />} />
<Route
path={AppPath.DevelopersCatchAll}
element={
<Routes>
<Route
path={SettingsPath.Developers}
element={<SettingsDevelopersApiKeys />}
/>
<Route
path={SettingsPath.DevelopersNewApiKey}
element={<SettingsDevelopersApiKeysNew />}
/>
<Route
path={SettingsPath.DevelopersApiKeyDetail}
element={<SettingsDevelopersApiKeyDetail />}
/>
</Routes>
}
/>
<Route
path={SettingsPath.ObjectNewFieldStep1}
element={<SettingsObjectNewFieldStep1 />}

View File

@ -760,6 +760,15 @@ export enum ViewType {
Table = 'Table'
}
export type WebHook = {
__typename?: 'WebHook';
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
operation: Scalars['String']['output'];
targetUrl: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type Workspace = {
__typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>;
@ -783,6 +792,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>;
};

View File

@ -482,6 +482,13 @@ export enum ApiKeyScalarFieldEnum {
WorkspaceId = 'workspaceId'
}
export type ApiKeyToken = {
__typename?: 'ApiKeyToken';
expiresAt: Scalars['DateTime'];
id: Scalars['String'];
token: Scalars['String'];
};
export type ApiKeyUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
@ -1392,7 +1399,7 @@ export type Mutation = {
createManyViewFilter: AffectedRows;
createManyViewSort: AffectedRows;
createOneActivity: Activity;
createOneApiKey: AuthToken;
createOneApiKey: ApiKeyToken;
createOneComment: Comment;
createOneCompany: Company;
createOneField: Field;
@ -1402,6 +1409,7 @@ export type Mutation = {
createOnePipelineStage: PipelineStage;
createOneView: View;
createOneViewField: ViewField;
createOneWebHook: WebHook;
deleteCurrentWorkspace: Workspace;
deleteFavorite: Favorite;
deleteManyActivities: AffectedRows;
@ -1415,6 +1423,7 @@ export type Mutation = {
deleteOneObject: ObjectDeleteResponse;
deleteOnePipelineStage: PipelineStage;
deleteOneView: View;
deleteOneWebHook: WebHook;
deleteUserAccount: User;
deleteWorkspaceMember: WorkspaceMember;
impersonate: Verify;
@ -1559,6 +1568,11 @@ export type MutationCreateOneViewFieldArgs = {
};
export type MutationCreateOneWebHookArgs = {
data: WebHookCreateInput;
};
export type MutationDeleteFavoriteArgs = {
where: FavoriteWhereInput;
};
@ -1609,6 +1623,11 @@ export type MutationDeleteOneViewArgs = {
};
export type MutationDeleteOneWebHookArgs = {
where: WebHookWhereUniqueInput;
};
export type MutationDeleteWorkspaceMemberArgs = {
where: WorkspaceMemberWhereUniqueInput;
};
@ -2523,6 +2542,7 @@ export type Query = {
findManyViewField: Array<ViewField>;
findManyViewFilter: Array<ViewFilter>;
findManyViewSort: Array<ViewSort>;
findManyWebHook: Array<WebHook>;
findManyWorkspaceMember: Array<WorkspaceMember>;
findUniqueCompany: Company;
findUniquePerson: Person;
@ -2662,6 +2682,16 @@ export type QueryFindManyViewSortArgs = {
};
export type QueryFindManyWebHookArgs = {
cursor?: InputMaybe<WebHookWhereUniqueInput>;
distinct?: InputMaybe<Array<WebHookScalarFieldEnum>>;
orderBy?: InputMaybe<Array<WebHookOrderByWithRelationInput>>;
skip?: InputMaybe<Scalars['Int']>;
take?: InputMaybe<Scalars['Int']>;
where?: InputMaybe<WebHookWhereInput>;
};
export type QueryFindManyWorkspaceMemberArgs = {
cursor?: InputMaybe<WorkspaceMemberWhereUniqueInput>;
distinct?: InputMaybe<Array<WorkspaceMemberScalarFieldEnum>>;
@ -3392,6 +3422,62 @@ export type ViewWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type WebHook = {
__typename?: 'WebHook';
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
operation: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt: Scalars['DateTime'];
};
export type WebHookCreateInput = {
createdAt?: InputMaybe<Scalars['DateTime']>;
id?: InputMaybe<Scalars['String']>;
operation: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
export type WebHookOrderByWithRelationInput = {
createdAt?: InputMaybe<SortOrder>;
id?: InputMaybe<SortOrder>;
operation?: InputMaybe<SortOrder>;
targetUrl?: InputMaybe<SortOrder>;
updatedAt?: InputMaybe<SortOrder>;
};
export enum WebHookScalarFieldEnum {
CreatedAt = 'createdAt',
DeletedAt = 'deletedAt',
Id = 'id',
Operation = 'operation',
TargetUrl = 'targetUrl',
UpdatedAt = 'updatedAt',
WorkspaceId = 'workspaceId'
}
export type WebHookUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<WebHookWhereUniqueInput>>;
disconnect?: InputMaybe<Array<WebHookWhereUniqueInput>>;
set?: InputMaybe<Array<WebHookWhereUniqueInput>>;
};
export type WebHookWhereInput = {
AND?: InputMaybe<Array<WebHookWhereInput>>;
NOT?: InputMaybe<Array<WebHookWhereInput>>;
OR?: InputMaybe<Array<WebHookWhereInput>>;
createdAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<StringFilter>;
operation?: InputMaybe<StringFilter>;
targetUrl?: InputMaybe<StringFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
};
export type WebHookWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type Workspace = {
__typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>;
@ -3415,6 +3501,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>;
};
@ -3590,6 +3677,7 @@ export type WorkspaceUpdateInput = {
viewFilters?: InputMaybe<ViewFilterUpdateManyWithoutWorkspaceNestedInput>;
viewSorts?: InputMaybe<ViewSortUpdateManyWithoutWorkspaceNestedInput>;
views?: InputMaybe<ViewUpdateManyWithoutWorkspaceNestedInput>;
webHooks?: InputMaybe<WebHookUpdateManyWithoutWorkspaceNestedInput>;
workspaceMember?: InputMaybe<WorkspaceMemberUpdateManyWithoutWorkspaceNestedInput>;
};
@ -4118,6 +4206,27 @@ export type SearchUserQueryVariables = Exact<{
export type SearchUserQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', avatarUrl?: string | null, id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null }> };
export type DeleteOneApiKeyMutationVariables = Exact<{
apiKeyId: Scalars['String'];
}>;
export type DeleteOneApiKeyMutation = { __typename?: 'Mutation', revokeOneApiKey: { __typename?: 'ApiKey', id: string } };
export type InsertOneApiKeyMutationVariables = Exact<{
data: ApiKeyCreateInput;
}>;
export type InsertOneApiKeyMutation = { __typename?: 'Mutation', createOneApiKey: { __typename?: 'ApiKeyToken', id: string, token: string, expiresAt: string } };
export type GetApiKeyQueryVariables = Exact<{
apiKeyId: Scalars['String'];
}>;
export type GetApiKeyQuery = { __typename?: 'Query', findManyApiKey: Array<{ __typename?: 'ApiKey', id: string, name: string, expiresAt?: string | null }> };
export type UserFieldsFragmentFragment = { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -6771,6 +6880,111 @@ export function useSearchUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions
export type SearchUserQueryHookResult = ReturnType<typeof useSearchUserQuery>;
export type SearchUserLazyQueryHookResult = ReturnType<typeof useSearchUserLazyQuery>;
export type SearchUserQueryResult = Apollo.QueryResult<SearchUserQuery, SearchUserQueryVariables>;
export const DeleteOneApiKeyDocument = gql`
mutation DeleteOneApiKey($apiKeyId: String!) {
revokeOneApiKey(where: {id: $apiKeyId}) {
id
}
}
`;
export type DeleteOneApiKeyMutationFn = Apollo.MutationFunction<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>;
/**
* __useDeleteOneApiKeyMutation__
*
* To run a mutation, you first call `useDeleteOneApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteOneApiKeyMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteOneApiKeyMutation, { data, loading, error }] = useDeleteOneApiKeyMutation({
* variables: {
* apiKeyId: // value for 'apiKeyId'
* },
* });
*/
export function useDeleteOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>(DeleteOneApiKeyDocument, options);
}
export type DeleteOneApiKeyMutationHookResult = ReturnType<typeof useDeleteOneApiKeyMutation>;
export type DeleteOneApiKeyMutationResult = Apollo.MutationResult<DeleteOneApiKeyMutation>;
export type DeleteOneApiKeyMutationOptions = Apollo.BaseMutationOptions<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>;
export const InsertOneApiKeyDocument = gql`
mutation InsertOneApiKey($data: ApiKeyCreateInput!) {
createOneApiKey(data: $data) {
id
token
expiresAt
}
}
`;
export type InsertOneApiKeyMutationFn = Apollo.MutationFunction<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>;
/**
* __useInsertOneApiKeyMutation__
*
* To run a mutation, you first call `useInsertOneApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInsertOneApiKeyMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [insertOneApiKeyMutation, { data, loading, error }] = useInsertOneApiKeyMutation({
* variables: {
* data: // value for 'data'
* },
* });
*/
export function useInsertOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>(InsertOneApiKeyDocument, options);
}
export type InsertOneApiKeyMutationHookResult = ReturnType<typeof useInsertOneApiKeyMutation>;
export type InsertOneApiKeyMutationResult = Apollo.MutationResult<InsertOneApiKeyMutation>;
export type InsertOneApiKeyMutationOptions = Apollo.BaseMutationOptions<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>;
export const GetApiKeyDocument = gql`
query GetApiKey($apiKeyId: String!) {
findManyApiKey(where: {id: {equals: $apiKeyId}}) {
id
name
expiresAt
}
}
`;
/**
* __useGetApiKeyQuery__
*
* To run a query within a React component, call `useGetApiKeyQuery` and pass it any options that fit your needs.
* When your component renders, `useGetApiKeyQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetApiKeyQuery({
* variables: {
* apiKeyId: // value for 'apiKeyId'
* },
* });
*/
export function useGetApiKeyQuery(baseOptions: Apollo.QueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export function useGetApiKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export type GetApiKeyQueryHookResult = ReturnType<typeof useGetApiKeyQuery>;
export type GetApiKeyLazyQueryHookResult = ReturnType<typeof useGetApiKeyLazyQuery>;
export type GetApiKeyQueryResult = Apollo.QueryResult<GetApiKeyQuery, GetApiKeyQueryVariables>;
export const DeleteUserAccountDocument = gql`
mutation DeleteUserAccount {
deleteUserAccount {

View File

@ -24,7 +24,7 @@ export const CompanyBoard = ({
onEditColumnTitle,
}: CompanyBoardProps) => {
// TODO: we can store objectId and fieldDefinitions in the ViewBarContext
// And then use the useBoardViews hook wherever we need it in the board
// And then use the useBoardViews web-hook wherever we need it in the board
const { createView, deleteView, submitCurrentView, updateView } =
useBoardViews({
objectId: 'company',

View File

@ -41,7 +41,7 @@ export const SettingsNavbar = () => {
end: false,
});
const isDevelopersSettingsActive = !!useMatch({
path: useResolvedPath('/settings/api').pathname,
path: useResolvedPath('/settings/developers/api-keys').pathname,
end: true,
});
@ -104,7 +104,7 @@ export const SettingsNavbar = () => {
{isDevelopersSettingsEnabled && (
<NavItem
label="Developers"
to="/settings/apis"
to="/settings/developers/api-keys"
Icon={IconRobot}
active={isDevelopersSettingsActive}
/>

View File

@ -0,0 +1,51 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCopy } from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { beautifyDateDiff } from '~/utils/date-utils';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type ApiKeyInputProps = { expiresAt?: string | null; apiKey: string };
export const ApiKeyInput = ({ expiresAt, apiKey }: ApiKeyInputProps) => {
const theme = useTheme();
const computeInfo = () => {
if (!expiresAt) {
return '';
}
return `This key will expire in ${beautifyDateDiff(expiresAt)}`;
};
const { enqueueSnackBar } = useSnackBar();
return (
<StyledContainer>
<StyledLinkContainer>
<TextInput info={computeInfo()} value={apiKey} fullWidth />
</StyledLinkContainer>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Api Key copied to clipboard', {
variant: 'success',
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(apiKey);
}}
/>
</StyledContainer>
);
};

View File

@ -25,7 +25,7 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsApisFieldItemTableRow = ({
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
}: {
fieldItem: ApisFiedlItem;

View File

@ -0,0 +1,19 @@
import { Meta, StoryObj } from '@storybook/react';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof ApiKeyInput> = {
title: 'Pages/Settings/Developers/ApiKeys/ApiKeyInput',
component: ApiKeyInput,
decorators: [ComponentDecorator],
args: {
expiresAt: '2123-11-06T23:59:59.825Z',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MTQyODgyLCJleHAiOjE2OTk0MDE1OTksImp0aSI6ImMyMmFiNjQxLTVhOGYtNGQwMC1iMDkzLTk3MzUwYTM2YzZkOSJ9.JIe2TX5IXrdNl3n-kRFp3jyfNUE7unzXZLAzm2Gxl98',
},
};
export default meta;
type Story = StoryObj<typeof ApiKeyInput>;
export const Default: Story = {};

View File

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator],
args: {
fieldItem: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key',
type: 'internal',
expiration: 'In 3 days',
},
},
};
export default meta;
type Story = StoryObj<typeof SettingsApiKeysFieldItemTableRow>;
export const Default: Story = {};

View File

@ -0,0 +1,11 @@
export const ExpirationDates: {
value: number;
label: string;
}[] = [
{ label: '15 days', value: 15 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
{ label: '1 year', value: 365 },
{ label: '2 years', value: 2 * 365 },
{ label: 'Never', value: 10 * 365 },
];

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_ONE_API_KEY = gql`
mutation DeleteOneApiKey($apiKeyId: String!) {
revokeOneApiKey(where: { id: $apiKeyId }) {
id
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const INSERT_ONE_API_KEY = gql`
mutation InsertOneApiKey($data: ApiKeyCreateInput!) {
createOneApiKey(data: $data) {
id
token
expiresAt
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_API_KEY = gql`
query GetApiKey($apiKeyId: String!) {
findManyApiKey(where: { id: { equals: $apiKeyId } }) {
id
name
expiresAt
}
}
`;

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const generatedApiKeyState = atom<string | null | undefined>({
key: 'generatedApiKeyState',
default: null,
});

View File

@ -36,7 +36,7 @@ export const NameFields = ({
const [updateUser] = useUpdateUserMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = debounce(async () => {
if (onFirstNameUpdate) {
onFirstNameUpdate(firstName);

View File

@ -34,7 +34,7 @@ export const NameField = ({
const [updateWorkspace] = useUpdateWorkspaceMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// 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(
debounce(async (name: string) => {

View File

@ -20,6 +20,7 @@ export enum AppPath {
ObjectTablePage = '/objects/:objectNamePlural',
SettingsCatchAll = `/settings/*`,
DevelopersCatchAll = `/developers/*`,
// Impersonate
Impersonate = '/impersonate/:userId',

View File

@ -9,5 +9,7 @@ export enum SettingsPath {
NewObject = 'objects/new',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
Apis = 'apis',
Developers = 'api-keys',
DevelopersNewApiKey = 'api-keys/new',
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
}

View File

@ -5,7 +5,7 @@ import { FieldMetadata } from '../types/FieldMetadata';
export type GenericFieldContextType = {
fieldDefinition: FieldDefinition<FieldMetadata>;
// TODO: add better typing for mutation hook
// TODO: add better typing for mutation web-hook
useUpdateEntityMutation: () => [(params: any) => void, any];
entityId: string;
recoilScopeId: string;

View File

@ -11,7 +11,7 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string> = {
export type SelectProps<Value extends string | number> = {
dropdownScopeId: string;
onChange: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
@ -38,7 +38,7 @@ const StyledLabel = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
export const Select = <Value extends string>({
export const Select = <Value extends string | number>({
dropdownScopeId,
onChange,
options,

View File

@ -25,6 +25,7 @@ export type TextInputComponentProps = Omit<
> & {
className?: string;
label?: string;
info?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
@ -45,10 +46,16 @@ const StyledLabel = styled.span`
text-transform: uppercase;
`;
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
@ -113,6 +120,7 @@ const TextInputComponent = (
{
className,
label,
info,
value,
onChange,
onFocus,
@ -204,6 +212,7 @@ const TextInputComponent = (
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{info && <StyledInfo>{info}</StyledInfo>}
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);

View File

@ -6,11 +6,11 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Select, SelectProps } from '../Select';
type RenderProps = SelectProps<string>;
type RenderProps = SelectProps<string | number>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string) => {
const handleChange = (value: string | number) => {
args.onChange?.(value);
setValue(value);
};

View File

@ -38,3 +38,7 @@ export const Filled: Story = {
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};
export const WithInfo: Story = {
args: { info: 'Some info displayed below the input', value: 'Tim' },
};

View File

@ -4,7 +4,7 @@ import { companyProgressesFamilyState } from '@/companies/states/companyProgress
import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFamilyState';
// TODO: this state should be computed during the synchronization hook and put in a generic
// TODO: this state should be computed during the synchronization web-hook and put in a generic
// boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',

View File

@ -22,7 +22,7 @@ const TestComponentDomMode = () => {
);
};
test('useListenClickOutside hook works in dom mode', async () => {
test('useListenClickOutside web-hook works in dom mode', async () => {
const { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside');
const inside2 = getByText('Inside 2');

View File

@ -136,7 +136,7 @@ export const CreateProfile = () => {
title="Name"
description="Your name as it will be displayed on the app"
/>
{/* TODO: When react-hook-form is added to edit page we should create a dedicated component with context */}
{/* TODO: When react-web-hook-form is added to edit page we should create a dedicated component with context */}
<StyledComboInputContainer>
<Controller
name="firstName"

View File

@ -1,25 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsApis } from '../SettingsApis';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsApi',
component: SettingsApis,
decorators: [PageDecorator],
args: { routePath: '/settings/apis' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsApis>;
export const Default: Story = {};

View File

@ -0,0 +1,76 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { generatedApiKeyState } from '@/settings/developers/states/generatedApiKeyState';
import { IconSettings, IconTrash } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
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 {
useDeleteOneApiKeyMutation,
useGetApiKeyQuery,
} from '~/generated/graphql';
export const SettingsDevelopersApiKeyDetail = () => {
const navigate = useNavigate();
const { apiKeyId = '' } = useParams();
const [generatedApiKey] = useRecoilState(generatedApiKeyState);
const apiKeyQuery = useGetApiKeyQuery({
variables: {
apiKeyId,
},
});
const [deleteApiKey] = useDeleteOneApiKeyMutation();
const deleteIntegration = async () => {
await deleteApiKey({ variables: { apiKeyId } });
navigate('/settings/developers/api-keys');
};
const { expiresAt, name } = apiKeyQuery.data?.findManyApiKey[0] || {};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: name || '' },
]}
/>
</SettingsHeaderContainer>
<Section>
<H2Title
title="Api Key"
description="Copy this key as it will only be visible this one time"
/>
<ApiKeyInput expiresAt={expiresAt} apiKey={generatedApiKey || ''} />
</Section>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={name || ''}
disabled={true}
fullWidth
/>
</Section>
<Section>
<H2Title title="Danger zone" description="Delete this integration" />
<Button
accent="danger"
variant="secondary"
title="Disable"
Icon={IconTrash}
onClick={deleteIntegration}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings';
import { SettingsApisFieldItemTableRow } from '@/settings/developers/components/SettingsApisFieldItemTableRow';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { activeApiKeyItems } from '@/settings/developers/constants/mockObjects';
import { IconPlus, IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
@ -34,7 +34,7 @@ const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsApis = () => {
export const SettingsDevelopersApiKeys = () => {
const navigate = useNavigate();
return (
@ -48,7 +48,7 @@ export const SettingsApis = () => {
accent="blue"
size="small"
onClick={() => {
navigate('/');
navigate('/settings/developers/api-keys/new');
}}
/>
</StyledHeader>
@ -64,7 +64,7 @@ export const SettingsApis = () => {
<TableHeader></TableHeader>
</StyledTableRow>
{activeApiKeyItems.map((fieldItem) => (
<SettingsApisFieldItemTableRow
<SettingsApiKeysFieldItemTableRow
key={fieldItem.id}
fieldItem={fieldItem}
/>

View File

@ -0,0 +1,100 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { DateTime } from 'luxon';
import { useRecoilState } from 'recoil';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ExpirationDates } from '@/settings/developers/constants/expirationDates';
import { generatedApiKeyState } from '@/settings/developers/states/generatedApiKeyState';
import { IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
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 { useInsertOneApiKeyMutation } from '~/generated/graphql';
export const SettingsDevelopersApiKeysNew = () => {
const [insertOneApiKey] = useInsertOneApiKeyMutation();
const navigate = useNavigate();
const [, setGeneratedApiKey] = useRecoilState(generatedApiKeyState);
const [formValues, setFormValues] = useState<{
name: string;
expirationDate: number;
}>({
expirationDate: ExpirationDates[0].value,
name: '',
});
const onSave = async () => {
const apiKey = await insertOneApiKey({
variables: {
data: {
name: formValues.name,
expiresAt: DateTime.now()
.plus({ days: formValues.expirationDate })
.toISODate(),
},
},
});
setGeneratedApiKey(apiKey.data?.createOneApiKey?.token);
navigate(
`/settings/developers/api-keys/${apiKey.data?.createOneApiKey?.id}`,
);
};
const canSave = !!formValues.name;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers/api-keys');
}}
onSave={onSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={formValues.name}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
name: value,
}));
}}
fullWidth
/>
</Section>
<Section>
<H2Title
title="Expiration Date"
description="When the API key will expire."
/>
<Select
dropdownScopeId="object-field-type-select"
options={ExpirationDates}
value={formValues.expirationDate}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
expirationDate: value,
}));
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeys',
component: SettingsDevelopersApiKeys,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeys>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedApiKeyToken } from '~/testing/mock-data/api-keys';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail',
component: SettingsDevelopersApiKeyDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/apis/f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
state: mockedApiKeyToken,
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeyDetail>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew',
component: SettingsDevelopersApiKeysNew,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys/new' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeysNew>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@ -8,23 +8,41 @@ import { UserProvider } from '~/modules/users/components/UserProvider';
import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout';
export type PageDecoratorArgs = { routePath: string; routeParams: RouteParams };
export type PageDecoratorArgs = {
routePath: string;
routeParams: RouteParams;
state?: string;
};
type RouteParams = {
[param: string]: string;
};
const computeLocation = (routePath: string, routeParams: RouteParams) =>
routePath.replace(/:(\w+)/g, (paramName) => routeParams[paramName] ?? '');
const computeLocation = (
routePath: string,
routeParams: RouteParams,
state?: string,
) => {
return {
pathname: routePath.replace(
/:(\w+)/g,
(paramName) => routeParams[paramName] ?? '',
),
state,
};
};
export const PageDecorator: Decorator<{
routePath: string;
routeParams: RouteParams;
state?: string;
}> = (Story, { args }) => (
<UserProvider>
<ClientConfigProvider>
<MemoryRouter
initialEntries={[computeLocation(args.routePath, args.routeParams)]}
initialEntries={[
computeLocation(args.routePath, args.routeParams, args.state),
]}
>
<FullHeightStorybookLayout>
<HelmetProvider>

View File

@ -17,6 +17,7 @@ import { SEARCH_ACTIVITY_QUERY } from '@/search/graphql/queries/searchActivityQu
import { SEARCH_COMPANY_QUERY } from '@/search/graphql/queries/searchCompanyQuery';
import { SEARCH_PEOPLE_QUERY } from '@/search/graphql/queries/searchPeopleQuery';
import { SEARCH_USER_QUERY } from '@/search/graphql/queries/searchUserQuery';
import { GET_API_KEY } from '@/settings/developers/graphql/queries/getApiKey';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { GET_VIEW_FIELDS } from '@/views/graphql/queries/getViewFields';
import { GET_VIEWS } from '@/views/graphql/queries/getViews';
@ -30,6 +31,7 @@ import {
SearchUserQuery,
ViewType,
} from '~/generated/graphql';
import { mockedApiKeys } from '~/testing/mock-data/api-keys';
import { mockedActivities, mockedTasks } from './mock-data/activities';
import {
@ -278,6 +280,13 @@ export const graphqlMocks = [
}),
);
}),
graphql.query(getOperationName(GET_API_KEY) ?? '', (req, res, ctx) => {
return res(
ctx.data({
findManyApiKey: mockedApiKeys[0],
}),
);
}),
graphql.mutation(
getOperationName(CREATE_ACTIVITY_WITH_COMMENT) ?? '',
(req, res, ctx) => {

View File

@ -0,0 +1,18 @@
import { ApiKey } from '~/generated/graphql';
type MockedApiKey = Pick<
ApiKey,
'id' | 'name' | 'createdAt' | 'updatedAt' | 'expiresAt' | '__typename'
>;
export const mockedApiKeyToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MDkzMDU0LCJleHAiOjE2OTkzMTUxOTksImp0aSI6IjY0Njg3ZWNmLWFhYzktNDNmYi1hY2I4LTE1M2QzNzgwYmIzMSJ9.JkQ3u7aRiqOFQkgHcC-mgCU37096HRSo40A_9X8gEng';
export const mockedApiKeys: Array<MockedApiKey> = [
{
id: 'f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
name: 'Zapier Integration',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
expiresAt: '2100-11-06T23:59:59.825Z',
__typename: 'ApiKey',
},
];

View File

@ -2,6 +2,7 @@ import { formatDistanceToNow } from 'date-fns';
import { DateTime } from 'luxon';
import {
beautifyDateDiff,
beautifyExactDate,
beautifyExactDateTime,
beautifyPastDateAbsolute,
@ -237,3 +238,47 @@ describe('hasDatePassed', () => {
expect(result).toEqual(false);
});
});
describe('beautifyDateDiff', () => {
it('should return the correct date diff', () => {
const date = '2023-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('4 days');
});
it('should return the correct date diff for large diff', () => {
const date = '2033-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('10 years and 4 days');
});
it('should return the correct date for negative diff', () => {
const date = '2013-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('-9 years and -361 days');
});
it('should return the correct date diff for large diff', () => {
const date = '2033-11-01T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('10 years');
});
it('should return the proper english date diff', () => {
const date = '2024-11-02T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('1 year and 1 day');
});
it('should round date diff', () => {
const date = '2024-11-03T14:04:43.421Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('1 year and 2 days');
});
it('should compare to now', () => {
const date = '2200-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date);
expect(result).toContain('years');
});
});

View File

@ -108,3 +108,17 @@ export const hasDatePassed = (date: Date | string | number) => {
return false;
}
};
export const beautifyDateDiff = (date: string, dateToCompareWith?: string) => {
const dateDiff = DateTime.fromISO(date).diff(
dateToCompareWith ? DateTime.fromISO(dateToCompareWith) : DateTime.now(),
['years', 'days'],
);
let result = '';
if (dateDiff.years) result = result + `${dateDiff.years} year`;
if (![0, 1].includes(dateDiff.years)) result = result + 's';
if (dateDiff.years && dateDiff.days) result = result + ' and ';
if (dateDiff.days) result = result + `${Math.floor(dateDiff.days)} day`;
if (![0, 1].includes(dateDiff.days)) result = result + 's';
return result;
};