Skip to content

Commit

Permalink
N21-2167 preferred tools for boards (#5282)
Browse files Browse the repository at this point in the history
* add preferred tool endpoint
* add preferred tool uc
* add apiProperty description
* add seed data
* add feature flag FEATURE_PREFERRED_CTL_TOOLS_ENABLED
---------
Co-authored-by: Steliyan Dinkov <[email protected]>
Co-authored-by: Igor Richter <[email protected]>
  • Loading branch information
MBergCap authored and HKayed committed Oct 28, 2024
1 parent 9b20e70 commit 012a285
Show file tree
Hide file tree
Showing 19 changed files with 594 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe(BoardNodeCopyService.name, () => {
CTL_TOOLS_RELOAD_TIME_MS: 0,
FILES_STORAGE__SERVICE_BASE_URL: '',
CTL_TOOLS__PREFERRED_TOOLS_LIMIT: 10,
FEATURE_PREFERRED_CTL_TOOLS_ENABLED: false,
};
let contextExternalToolService: DeepMocked<ContextExternalToolService>;
let copyHelperService: DeepMocked<CopyHelperService>;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/modules/server/admin-api-server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const config: AdminApiServerConfig = {
FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean,
FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean,
CTL_TOOLS__PREFERRED_TOOLS_LIMIT: Configuration.get('CTL_TOOLS__PREFERRED_TOOLS_LIMIT') as number,
FEATURE_PREFERRED_CTL_TOOLS_ENABLED: Configuration.get('FEATURE_PREFERRED_CTL_TOOLS_ENABLED') as boolean,
TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get(
'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION'
) as string,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/modules/server/api/dto/config.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export class ConfigResponse {
@ApiProperty()
FEATURE_CTL_TOOLS_COPY_ENABLED: boolean;

@ApiProperty()
FEATURE_PREFERRED_CTL_TOOLS_ENABLED: boolean;

@ApiProperty()
FEATURE_SHOW_MIGRATION_WIZARD: boolean;

Expand Down Expand Up @@ -291,6 +294,7 @@ export class ConfigResponse {
this.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED;
this.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED;
this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.FEATURE_CTL_TOOLS_COPY_ENABLED;
this.FEATURE_PREFERRED_CTL_TOOLS_ENABLED = config.FEATURE_PREFERRED_CTL_TOOLS_ENABLED;
this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD;
this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK;
this.FEATURE_TLDRAW_ENABLED = config.FEATURE_TLDRAW_ENABLED;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/modules/server/api/test/server.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('Server Controller (API)', () => {
'FEATURE_COURSE_SHARE',
'FEATURE_CTL_TOOLS_COPY_ENABLED',
'FEATURE_CTL_TOOLS_TAB_ENABLED',
'FEATURE_PREFERRED_CTL_TOOLS_ENABLED',
'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION',
'FEATURE_ES_COLLECTIONS_ENABLED',
'FEATURE_EXTENSIONS_ENABLED',
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/modules/server/server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ const config: ServerConfig = {
) as number,
CTL_TOOLS_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string,
FEATURE_CTL_TOOLS_COPY_ENABLED: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean,
FEATURE_PREFERRED_CTL_TOOLS_ENABLED: Configuration.get('FEATURE_PREFERRED_CTL_TOOLS_ENABLED') as boolean,
CTL_TOOLS_RELOAD_TIME_MS: Configuration.get('CTL_TOOLS_RELOAD_TIME_MS') as number,
HOST: Configuration.get('HOST') as string,
FILES_STORAGE__SERVICE_BASE_URL: Configuration.get('FILES_STORAGE__SERVICE_BASE_URL') as string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
SchoolExternalToolConfigurationTemplateListResponse,
SchoolExternalToolConfigurationTemplateResponse,
ToolContextTypesListResponse,
PreferredToolListResponse,
} from '../dto';

describe('ToolConfigurationController (API)', () => {
Expand Down Expand Up @@ -743,4 +744,175 @@ describe('ToolConfigurationController (API)', () => {
});
});
});

describe('[GET] tools/preferred-tools', () => {
describe('when the user is not authorized', () => {
const setup = async () => {
const school: SchoolEntity = schoolEntityFactory.buildWithId();

const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin();

const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({
isPreferred: true,
iconName: 'iconName',
});

const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({
school,
tool: externalTool,
});

await em.persistAndFlush([school, adminUser, adminAccount, externalTool, schoolExternalTool]);
em.clear();

const loggedInClient: TestApiClient = await testApiClient.login(adminAccount);

return {
loggedInClient,
};
};

it('should return a unauthorized status', async () => {
const { loggedInClient } = await setup();

const response: Response = await loggedInClient.get(`/preferred-tools`);

expect(response.status).toEqual(HttpStatus.UNAUTHORIZED);
});
});

describe('when preferred tools are available for a context', () => {
const setup = async () => {
const school: SchoolEntity = schoolEntityFactory.buildWithId();

const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [
Permission.CONTEXT_TOOL_ADMIN,
]);

const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({
restrictToContexts: [],
isPreferred: true,
iconName: 'iconName',
});

const externalToolWithContextRestriction: ExternalToolEntity = externalToolEntityFactory.buildWithId({
restrictToContexts: [ToolContextType.COURSE],
isPreferred: true,
iconName: 'iconName',
});

const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({
school,
tool: externalTool,
});

const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({
school,
tool: externalToolWithContextRestriction,
});

await em.persistAndFlush([
school,
teacherUser,
teacherAccount,
externalTool,
externalToolWithContextRestriction,
schoolExternalTool,
schoolExternalTool2,
]);
em.clear();

const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount);

return {
externalTool,
externalToolWithContextRestriction,
schoolExternalTool,
schoolExternalTool2,
loggedInClient,
};
};

it('should return an array of preferred tools', async () => {
const {
externalTool,
externalToolWithContextRestriction,
schoolExternalTool,
schoolExternalTool2,
loggedInClient,
} = await setup();

const response: Response = await loggedInClient.get('/preferred-tools');

expect(response.body).toEqual<PreferredToolListResponse>({
data: [
{
schoolExternalToolId: schoolExternalTool.id,
name: externalTool.name,
iconName: 'iconName',
},
{
schoolExternalToolId: schoolExternalTool2.id,
name: externalToolWithContextRestriction.name,
iconName: 'iconName',
},
],
});
});

it('should not return the context restricted tool', async () => {
const { loggedInClient, externalTool, schoolExternalTool } = await setup();

const response: Response = await loggedInClient.get('/preferred-tools').query({ contextType: 'board-element' });

expect(response.body).toEqual<PreferredToolListResponse>({
data: [
{
schoolExternalToolId: schoolExternalTool.id,
name: externalTool.name,
iconName: 'iconName',
},
],
});
});
});

describe('when no preferred tools are available', () => {
const setup = async () => {
const school: SchoolEntity = schoolEntityFactory.buildWithId();

const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({}, [
Permission.CONTEXT_TOOL_ADMIN,
]);

const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({
isPreferred: false,
});

const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({
school,
tool: externalTool,
});

await em.persistAndFlush([teacherUser, school, teacherAccount, externalTool, schoolExternalTool]);
em.clear();

const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount);

return {
loggedInClient,
};
};

it('should return an empty array', async () => {
const { loggedInClient } = await setup();

const response: Response = await loggedInClient.get('/preferred-tools');

expect(response.body).toEqual<PreferredToolListResponse>({
data: [],
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './school-external-tool-id.params';
export * from './context-external-tool-id.params';
export { ExternalToolMediumParams } from './external-tool-medium.params';
export { ExternalToolBulkCreateParams } from './external-tool-bulk-create.params';
export * from './tool-context-type.params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsEnum, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ToolContextType } from '@modules/tool/common/enum';

export class ToolContextTypeParams {
@IsOptional()
@IsEnum(ToolContextType, { each: true })
@ApiPropertyOptional({
enum: ToolContextType,
enumName: 'ToolContextType',
description: 'Context types for tools',
})
contextType?: ToolContextType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export {
ExternalToolImportResultListResponse,
ExternalToolImportResultResponse,
} from './external-tool-import-result-response';
export * from './preferred-tool.response';
export * from './preferred-tool-list.response';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { PreferredToolResponse } from './preferred-tool.response';

export class PreferredToolListResponse {
@ApiProperty({ type: [PreferredToolResponse] })
data: PreferredToolResponse[];

constructor(data: PreferredToolResponse[]) {
this.data = data;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';

export class PreferredToolResponse {
@ApiProperty({ type: String, description: 'Id of the school external tool' })
schoolExternalToolId: string;

@ApiProperty({ type: String, description: 'Name of the external tool' })
name: string;

@ApiProperty({
type: String,
description: 'Name of the icon to be rendered when displaying it as a preferred tool',
})
iconName: string;

constructor(configuration: PreferredToolResponse) {
this.schoolExternalToolId = configuration.schoolExternalToolId;
this.name = configuration.name;
this.iconName = configuration.iconName;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard';
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiForbiddenResponse,
ApiFoundResponse,
Expand All @@ -22,6 +22,8 @@ import {
SchoolExternalToolIdParams,
SchoolIdParams,
ToolContextTypesListResponse,
PreferredToolListResponse,
ToolContextTypeParams,
} from './dto';

@ApiTags('Tool')
Expand Down Expand Up @@ -95,6 +97,29 @@ export class ToolConfigurationController {
return mapped;
}

@Get('preferred-tools')
@ApiForbiddenResponse()
@ApiOperation({ summary: 'Lists all preferred tools that can be added for a given context' })
@ApiOkResponse({
description: 'List of preferred tools for a context',
type: PreferredToolListResponse,
})
public async getPreferredToolsForContext(
@CurrentUser() currentUser: ICurrentUser,
@Query() context: ToolContextTypeParams
): Promise<PreferredToolListResponse> {
const preferedTools: ContextExternalToolTemplateInfo[] =
await this.externalToolConfigurationUc.getPreferedToolsForContext(
currentUser.userId,
currentUser.schoolId,
context.contextType
);

const mapped: PreferredToolListResponse = ToolConfigurationMapper.mapToPreferredToolListResponse(preferedTools);

return mapped;
}

@Get('school-external-tools/:schoolExternalToolId/configuration-template')
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
SchoolExternalToolConfigurationTemplateListResponse,
SchoolExternalToolConfigurationTemplateResponse,
ToolContextTypesListResponse,
PreferredToolListResponse,
PreferredToolResponse,
} from '../controller/dto';
import { ExternalTool } from '../domain';
import { ContextExternalToolTemplateInfo } from '../uc';
Expand Down Expand Up @@ -77,4 +79,24 @@ export class ToolConfigurationMapper {

return mappedTypes;
}

static mapToPreferredToolListResponse(preferedTools: ContextExternalToolTemplateInfo[]): PreferredToolListResponse {
const mappedTools = preferedTools.map((tool): PreferredToolResponse => this.mapToPreferredToolResponse(tool));

const mapped = new PreferredToolListResponse(mappedTools);

return mapped;
}

static mapToPreferredToolResponse(preferredTool: ContextExternalToolTemplateInfo): PreferredToolResponse {
const { externalTool, schoolExternalTool } = preferredTool;

const mapped = new PreferredToolResponse({
schoolExternalToolId: schoolExternalTool.id ?? '',
name: externalTool.name,
iconName: externalTool.iconName ?? '',
});

return mapped;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,6 @@ export const externalToolFactory = ExternalToolFactory.define(ExternalTool, ({ s
createdAt: new Date(2020, 1, 1),
restrictToContexts: undefined,
isPreferred: false,
iconName: undefined,
};
});
Loading

0 comments on commit 012a285

Please sign in to comment.