Skip to content

Commit

Permalink
Merge pull request #691 from ministryofjustice/APG-250-add-course
Browse files Browse the repository at this point in the history
(APG-250) Add Course form page
  • Loading branch information
jsrobertson authored Jul 25, 2024
2 parents 1a26c6a + 3f408c4 commit b90f209
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 1 deletion.
4 changes: 3 additions & 1 deletion server/@types/models/CourseAudience.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { TagColour } from '@accredited-programmes/ui'

type Audience = {
id: string // eslint-disable-next-line @typescript-eslint/member-ordering
colour: string
colour: TagColour
name: CourseAudience
}

Expand Down
89 changes: 89 additions & 0 deletions server/controllers/find/addCourseController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { DeepMocked } from '@golevelup/ts-jest'
import { createMock } from '@golevelup/ts-jest'
import type { NextFunction, Request, Response } from 'express'

import AddCourseController from './addCourseController'
import { findPaths } from '../../paths'
import type { CourseService } from '../../services'
import { audienceFactory, courseFactory } from '../../testutils/factories'
import { CourseUtils } from '../../utils'
import type { GovukFrontendSelectItem } from '@govuk-frontend'

jest.mock('../../utils/courseUtils')

const mockCourseUtils = CourseUtils as jest.Mocked<typeof CourseUtils>

describe('AddCourseController', () => {
let controller: AddCourseController
let request: Request
let response: Response

const username = 'SOME_USERNAME'
const userToken = 'USER_TOKEN'
const courseService = createMock<CourseService>({})
const next: DeepMocked<NextFunction> = createMock<NextFunction>({})

beforeEach(() => {
controller = new AddCourseController(courseService)
request = createMock<Request>({ user: { token: userToken, username } })
response = createMock<Response>()
})

describe('show', () => {
const audienceSelectItems: Array<GovukFrontendSelectItem> = [
{ text: 'Audience 1', value: '1' },
{ text: 'Audience 2', value: '2' },
{ text: 'Audience 3', value: '3' },
]

beforeEach(() => {
mockCourseUtils.audienceSelectItems.mockReturnValue(audienceSelectItems)
})

it('renders the create course form template with audience select items', async () => {
const audiences = audienceFactory.buildList(3)
courseService.getCourseAudiences.mockResolvedValue(audiences)

const requestHandler = controller.show()
await requestHandler(request, response, next)

expect(courseService.getCourseAudiences).toHaveBeenCalledWith(userToken)

expect(response.render).toHaveBeenCalledWith('courses/form/show', {
audienceSelectItems,
pageHeading: 'Add a Programme',
})
expect(CourseUtils.audienceSelectItems).toHaveBeenCalledWith(audiences)
})
})

describe('submit', () => {
it('creates a course and redirects to the newly created course page', async () => {
const audience = audienceFactory.build()
const newCourseBody: Record<string, string> = {
alternateName: 'TC+1',
audienceId: audience.id,
description: 'Test course description',
name: 'Test Course',
withdrawn: 'false',
}
const createdCourse = courseFactory.build({
alternateName: newCourseBody.alternateName,
audience: audience.name,
audienceColour: audience.colour,
description: newCourseBody.description,
name: newCourseBody.name,
})

courseService.createCourse.mockResolvedValue(createdCourse)

request.body = newCourseBody

const requestHandler = controller.submit()
await requestHandler(request, response, next)

expect(courseService.createCourse).toHaveBeenCalledWith(username, { ...newCourseBody, withdrawn: false })
expect(response.redirect).toHaveBeenCalledWith(findPaths.show({ courseId: createdCourse.id }))
})
})
})
40 changes: 40 additions & 0 deletions server/controllers/find/addCourseController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Request, Response, TypedRequestHandler } from 'express'

import { findPaths } from '../../paths'
import type { CourseService } from '../../services'
import { CourseUtils, TypeUtils } from '../../utils'

export default class AddCourseController {
constructor(private readonly courseService: CourseService) {}

show(): TypedRequestHandler<Request, Response> {
return async (req: Request, res: Response) => {
TypeUtils.assertHasUser(req)

const audiences = await this.courseService.getCourseAudiences(req.user.token)

res.render('courses/form/show', {
audienceSelectItems: CourseUtils.audienceSelectItems(audiences),
pageHeading: 'Add a Programme',
})
}
}

submit(): TypedRequestHandler<Request, Response> {
return async (req: Request, res: Response) => {
TypeUtils.assertHasUser(req)

const { audienceId, name, alternateName, description, withdrawn } = req.body as Record<string, string>

const createdCourse = await this.courseService.createCourse(req.user.username, {
alternateName,
audienceId,
description,
name,
withdrawn: withdrawn === 'true',
})

res.redirect(findPaths.show({ courseId: createdCourse.id }))
}
}
}
3 changes: 3 additions & 0 deletions server/controllers/find/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/* istanbul ignore file */

import AddCourseController from './addCourseController'
import CourseOfferingsController from './courseOfferingsController'
import CoursesController from './coursesController'
import type { Services } from '../../services'

const controllers = (services: Services) => {
const addCourseController = new AddCourseController(services.courseService)
const coursesController = new CoursesController(services.courseService, services.organisationService)
const courseOfferingsController = new CourseOfferingsController(services.courseService, services.organisationService)

return {
addCourseController,
courseOfferingsController,
coursesController,
}
Expand Down
1 change: 1 addition & 0 deletions server/middleware/roleBasedAccessMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import logger from '../../logger'
import type { MiddlewareOptions } from '@accredited-programmes/users'

export enum ApplicationRoles {
ACP_EDITOR = 'ROLE_ACP_EDITOR',
ACP_PROGRAMME_TEAM = 'ROLE_ACP_PROGRAMME_TEAM',
ACP_REFERRER = 'ROLE_ACP_REFERRER',
}
Expand Down
8 changes: 8 additions & 0 deletions server/paths/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ const findPathBase = path('/find')
const coursesPath = findPathBase.path('/programmes')
const coursePath = coursesPath.path(':courseId')

const addCoursePath = coursesPath.path('/add')

const courseOfferingPath = findPathBase.path('/offerings/:courseOfferingId')

export default {
course: {
add: {
create: addCoursePath,
show: addCoursePath,
},
},
index: coursesPath,
offerings: {
show: courseOfferingPath,
Expand Down
16 changes: 16 additions & 0 deletions server/routes/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Router } from 'express'

import type { Controllers } from '../controllers'
import { ApplicationRoles } from '../middleware'
import { findPaths } from '../paths'
import { RouteUtils } from '../utils'

export default function routes(controllers: Controllers, router: Router): Router {
const { get, post } = RouteUtils.actions(router, { allowedRoles: [ApplicationRoles.ACP_EDITOR] })
const { addCourseController } = controllers

get(findPaths.course.add.show.pattern, addCourseController.show())
post(findPaths.course.add.create.pattern, addCourseController.submit())

return router
}
2 changes: 2 additions & 0 deletions server/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express'

import assessRoutes from './assess'
import editorRoutes from './editor'
import findRoutes from './find'
import referRoutes from './refer'
import config from '../config'
Expand All @@ -14,6 +15,7 @@ export default function routes(controllers: Controllers): Router {
const { dashboardController } = controllers
get('/', dashboardController.index())

editorRoutes(controllers, router)
findRoutes(controllers, router)
if (config.flags.referEnabled) {
assessRoutes(controllers, router)
Expand Down
83 changes: 83 additions & 0 deletions server/views/courses/form/show.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{% from "govuk/components/button/macro.njk" import govukButton %}
{% from "govuk/components/input/macro.njk" import govukInput %}
{% from "govuk/components/radios/macro.njk" import govukRadios %}
{% from "govuk/components/select/macro.njk" import govukSelect %}
{% from "govuk/components/textarea/macro.njk" import govukTextarea %}

{% extends "../../partials/layout.njk" %}

{% block content %}
<h1 class="govuk-heading-l">{{ pageHeading }}</h1>

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrfToken }}"/>

{{ govukSelect({
id: "programme-audience",
name: "audienceId",
label: {
text: "Audience"
},
items: audienceSelectItems,
attributes: {
"data-testid": "audience-select"
},
errorMessage: errors.messages.audience
}) }}

{{ govukInput({
label: {
text: "Programme name"
},
id: "programme-name",
name: "name",
errorMessage: errors.messages.name
}) }}

{{ govukInput({
label: {
text: "Alternative name (optional)"
},
classes: "govuk-input--width-10",
id: "programme-alternate-name",
name: "alternateName",
hint: {
text: "Usually an abbreviation or acronym for the programme name"
},
errorMessage: errors.messages.alternateName
}) }}

{{ govukTextarea({
name: "description",
id: "programme-description",
label: {
text: "Description"
}
}) }}

{{ govukRadios({
name: "withdrawn",
fieldset: {
legend: {
text: "Withdrawn"
}
},
items: [
{
value: "true",
text: "Yes"
},
{
value: "false",
text: "No"
}
]
}) }}

{{ govukButton({ text: "Submit" }) }}
</form>
</div>
</div>
{% endblock content %}

0 comments on commit b90f209

Please sign in to comment.