Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nv 2452 tenant create + update a tenant sidebar #3863

Merged
17 changes: 15 additions & 2 deletions apps/api/src/app/shared/framework/response.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {

return next.handle().pipe(
map((data) => {
// For paginated results that already contain the data wrapper, return the whole object
if (data?.data) {
if (this.returnWholeObject(data)) {
return {
...data,
data: isObject(data.data) ? this.transformResponse(data.data) : data.data,
Expand All @@ -31,6 +30,20 @@ export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
);
}

/**
* This method is used to determine if the entire object should be returned or just the data property
* for paginated results that already contain the data wrapper, true.
* for single entity result that *could* contain data object, false.
* @param data
* @private
*/
private returnWholeObject(data) {
const isPaginatedResult = data?.data;
const isEntityObject = data?._id;

return isPaginatedResult && !isEntityObject;
}

private transformResponse(response) {
if (isArray(response)) {
return response.map((item) => this.transformToPlain(item));
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/app/tenant/dtos/create-tenant-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { TenantCustomData } from '@novu/shared';
import { ICreateTenantDto, TenantCustomData } from '@novu/shared';

export class CreateTenantRequestDto {
export class CreateTenantRequestDto implements ICreateTenantDto {
@ApiProperty()
identifier: string;

@ApiProperty()
name?: string;
name: string;

@ApiProperty()
data?: TenantCustomData;
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/tenant/dtos/update-tenant-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TenantCustomData } from '@novu/shared';
import { IUpdateTenantDto, TenantCustomData } from '@novu/shared';
import { IsOptional, IsString } from 'class-validator';

export class UpdateTenantRequestDto {
export class UpdateTenantRequestDto implements IUpdateTenantDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String })
Expand Down
12 changes: 7 additions & 5 deletions apps/api/src/app/tenant/e2e/get-tenant.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ describe('Get Tenant - /tenants/:identifier (GET)', function () {
async function getTenant({ session, identifier }: { session; identifier: string }): Promise<AxiosResponse> {
const axiosInstance = axios.create();

return await axiosInstance.get(`${session.serverUrl}/v1/tenants/${identifier}`, {
headers: {
authorization: `ApiKey ${session.apiKey}`,
},
});
return (
await axiosInstance.get(`${session.serverUrl}/v1/tenants/${identifier}`, {
headers: {
authorization: `ApiKey ${session.apiKey}`,
},
})
).data;
}
8 changes: 4 additions & 4 deletions apps/api/src/app/tenant/tenant.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@ export class TenantController {
description: `Get tenant by your internal id used to identify the tenant`,
})
@ApiNotFoundResponse({
description: 'The tenant with the identifier provided does not exist in the database so it can not be deleted.',
description: 'The tenant with the identifier provided does not exist in the database.',
})
@ExternalApiAccessible()
getTenantById(
async getTenantById(
@UserSession() user: IJwtPayload,
@Param('identifier') identifier: string
): Promise<GetTenantResponseDto> {
return this.getTenantUsecase.execute(
return await this.getTenantUsecase.execute(
GetTenantCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
Expand Down Expand Up @@ -142,7 +142,7 @@ export class TenantController {
description: 'Update tenant by your internal id used to identify the tenant',
})
@ApiNotFoundResponse({
description: 'The tenant with the identifier provided does not exist in the database so it can not be deleted.',
description: 'The tenant with the identifier provided does not exist in the database.',
})
async updateTenant(
@UserSession() user: IJwtPayload,
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import { CreateProviderPage } from './pages/integrations/CreateProviderPage';
import { UpdateProviderPage } from './pages/integrations/UpdateProviderPage';
import { SelectProviderPage } from './pages/integrations/components/SelectProviderPage';
import { TenantsPage } from './pages/tenants/TenantsPage';
import { CreateTenantPage } from './pages/tenants/CreateTenantPage';
import { UpdateTenantPage } from './pages/tenants/UpdateTenantPage';

library.add(far, fas);

Expand Down Expand Up @@ -201,7 +203,10 @@ function App() {
<Route path=":channel/:stepUuid" element={<TemplateEditor />} />
</Route>
<Route path={ROUTES.WORKFLOWS} element={<WorkflowListPage />} />
<Route path={ROUTES.TENANTS} element={<TenantsPage />} />
<Route path={ROUTES.TENANTS} element={<TenantsPage />}>
<Route path="create" element={<CreateTenantPage />} />
<Route path=":identifier" element={<UpdateTenantPage />} />
</Route>
<Route path={ROUTES.GET_STARTED} element={<GetStarted />} />
<Route path={ROUTES.GET_STARTED_PREVIEW} element={<DigestPreview />} />
<Route path={ROUTES.QUICK_START_NOTIFICATION_CENTER} element={<NotificationCenter />} />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/api/query.keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface IQueryKeys {
getInAppActive: string;
getTemplateById: (templateId?: string) => string;
tenantsList: string;
getTenantByIdentifier: (tenantIdentifier?: string) => string;
}

export const QueryKeys: IQueryKeys = Object.freeze({
Expand All @@ -32,4 +33,5 @@ export const QueryKeys: IQueryKeys = Object.freeze({
getInAppActive: 'inAppActive',
getTemplateById: (templateId?: string) => `notificationById:${templateId}`,
tenantsList: 'tenantsList',
getTenantByIdentifier: (tenantIdentifier?: string) => `tenantByIdentifier:${tenantIdentifier}`,
});
13 changes: 13 additions & 0 deletions apps/web/src/api/tenants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { ICreateTenantDto, IUpdateTenantDto } from '@novu/shared';
import { api } from './api.client';

export function getTenants({ page = 0, limit = 10 } = {}) {
return api.getFullResponse('/v1/tenants', { page, limit });
}

export function createTenant(data: ICreateTenantDto) {
return api.post(`/v1/tenants`, data);
}

export function updateTenant(identifier: string, data: IUpdateTenantDto) {
return api.patch(`/v1/tenants/${identifier}`, data);
}

export function getTenantByIdentifier(identifier: string) {
return api.get(`/v1/tenants/${identifier}`);
}
1 change: 1 addition & 0 deletions apps/web/src/constants/routes.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum ROUTES {
WORKFLOWS_EDIT_TEMPLATEID = '/workflows/edit/:templateId',
WORKFLOWS = '/workflows',
TENANTS = '/tenants',
TENANTS_CREATE = '/tenants/create',
QUICKSTART = '/quickstart',
GET_STARTED = '/get-started',
GET_STARTED_PREVIEW = '/get-started/preview',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/design-system/config/inputs.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const inputStyles = (theme: MantineTheme) => {
},
input: {
minHeight: '50px',
borderRadius: '7px',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one won't break anything?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, all inputs should be with border-radius of 7px. But usually it was just the default from mantine

borderColor: dark ? theme.colors.dark[5] : theme.colors.gray[5],
backgroundColor: 'transparent',
color: primaryColor,
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/pages/tenants/CreateTenantPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useNavigate } from 'react-router-dom';

import { ROUTES } from '../../constants/routes.enum';
import { CreateTenantSidebar } from './components/CreateTenantSidebar';

export function CreateTenantPage() {
const navigate = useNavigate();

const onClose = () => {
navigate(ROUTES.TENANTS);
};

const onTenantCreated = (identifier: string) => {
navigate(`${ROUTES.TENANTS}/${identifier}`);
};

return <CreateTenantSidebar isOpened onTenantCreated={onTenantCreated} onClose={onClose} />;
}
17 changes: 14 additions & 3 deletions apps/web/src/pages/tenants/TenantsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Outlet, useNavigate } from 'react-router-dom';
import { Row } from 'react-table';
import { ITenantEntity } from '@novu/shared';

import PageContainer from '../../components/layout/components/PageContainer';
import PageHeader from '../../components/layout/components/PageHeader';
import { TenantsList } from './components/list/TenantsList';
import { ROUTES } from '../../constants/routes.enum';

export function TenantsPage() {
const navigate = useNavigate();

const onAddTenantClickCallback = useCallback(() => {
// navigate();
navigate(ROUTES.TENANTS_CREATE);
}, [navigate]);

const onRowClickCallback = useCallback(
(item: Row<ITenantEntity>) => {
navigate(`${ROUTES.TENANTS}/${item.original.identifier}`);
},
[navigate]
);

return (
<PageContainer title="Tenants">
<PageHeader title="Tenants" />
<TenantsList onAddTenantClick={onAddTenantClickCallback} />
<TenantsList onAddTenantClick={onAddTenantClickCallback} onRowClickCallback={onRowClickCallback} />
<Outlet />
</PageContainer>
);
}
21 changes: 21 additions & 0 deletions apps/web/src/pages/tenants/UpdateTenantPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useNavigate, useParams } from 'react-router-dom';

import { ROUTES } from '../../constants/routes.enum';
import { UpdateTenantSidebar } from './components/UpdateTenantSidebar';

export function UpdateTenantPage() {
const { identifier } = useParams();
ainouzgali marked this conversation as resolved.
Show resolved Hide resolved
const navigate = useNavigate();

const onClose = () => {
navigate(ROUTES.TENANTS);
};

if (!identifier) {
navigate(ROUTES.TENANTS);

return null;
}

return <UpdateTenantSidebar isOpened onClose={onClose} tenantIdentifier={identifier} />;
}
129 changes: 129 additions & 0 deletions apps/web/src/pages/tenants/components/CreateTenantSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect } from 'react';
import { Group, Stack } from '@mantine/core';
import { useForm } from 'react-hook-form';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import slugify from 'slugify';

import { ICreateTenantDto, ITenantEntity } from '@novu/shared';

import { Button, colors, Sidebar, Text, Title, Tooltip } from '../../../design-system';
import { createTenant } from '../../../api/tenants';
import { errorMessage, successMessage } from '../../../utils/notifications';
import { QueryKeys } from '../../../api/query.keys';
import { TenantFormCommonFields } from './TenantFormCommonFields';
import { defaultFormValues, ITenantForm } from './UpdateTenantSidebar';

export function CreateTenantSidebar({
isOpened,
onTenantCreated,
onClose,
}: {
isOpened: boolean;
onTenantCreated: (identifier: string) => void;
onClose: () => void;
}) {
const queryClient = useQueryClient();

const { mutateAsync: createTenantMutation, isLoading: isLoadingCreate } = useMutation<
ITenantEntity,
{ error: string; message: string; statusCode: number },
ICreateTenantDto
>(createTenant, {
onSuccess: async () => {
await queryClient.refetchQueries({
predicate: ({ queryKey }) => queryKey.includes(QueryKeys.tenantsList),
});
successMessage('New tenant has been created!');
},
onError: (e: any) => {
errorMessage(e.message || 'Unexpected error');
},
});

const {
handleSubmit,
control,
formState: { isValid, isDirty },
setValue,
watch,
} = useForm<ITenantForm>({
shouldUseNativeValidation: false,
defaultValues: defaultFormValues,
});

const name = watch('name');
const identifier = watch('identifier');

useEffect(() => {
const newIdentifier = slugify(name, {
lower: true,
strict: true,
});

if (newIdentifier === identifier) {
return;
}

setValue('identifier', newIdentifier);
}, [name]);

const onCreateTenant = async (data) => {
const { identifier: tenantIdentifier } = await createTenantMutation({
name: data.name,
identifier: data.identifier,
...(data.data ? { data: JSON.parse(data.data) } : {}),
});

if (!tenantIdentifier) {
onClose();
} else {
onTenantCreated(tenantIdentifier);
}
};

return (
<Sidebar
isOpened={isOpened}
onClose={onClose}
onSubmit={(e) => {
handleSubmit(onCreateTenant)(e);
e.stopPropagation();
}}
customHeader={
<Stack h={80} spacing={8}>
<Group h={40}>
<Title size={2}>Create a tenant</Title>
</Group>
<Text color={colors.B40}>
Tenants are isolated user scopes in your product, e.g., accounts or workspaces.
</Text>
</Stack>
}
customFooter={
<Group ml="auto">
<Button variant={'outline'} onClick={onClose} data-test-id="create-tenant-sidebar-cancel">
Cancel
</Button>
<Tooltip
sx={{ position: 'absolute' }}
disabled={isDirty}
label={'Fill in the name and identifier to create the tenant'}
>
<span>
<Button
loading={isLoadingCreate}
disabled={!isDirty || !isValid}
submit
data-test-id="create-tenant-sidebar-submit"
>
Create
</Button>
</span>
</Tooltip>
</Group>
}
>
<TenantFormCommonFields control={control} />
</Sidebar>
);
}
Loading