diff --git a/README.md b/README.md index 18913b2..894ef84 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,66 @@ Components can be called as macros: - `hmpoTextCount(ctx, params)`: Generates a text area with a character or word count limit, applying validation, error messages, accessibility attributes, and custom messages for under, at, and over-limit states. +- `hmpoTaskList(ctx, params, fieldName)`: Renders a GOV.UK task list showing task titles, status (completed/incomplete), and href links. All labels and status text are resolved from locale strings using the provided field name. Used for displaying application progress or workflow steps in a display-only format. + +#### Task list usage + +Define the field with a `type` of `task-list`, a `statuses` map, and an array of `tasks`: + +```js +// fields.js +module.exports = { + taskList: { + type: 'task-list', + statuses: { + default: 'blue', // blue tag for incomplete tasks + completed: false // false = plain text, no tag + }, + tasks: [ + { id: 'personalDetails', href: '/apply/your-name', statusField: 'personalDetailsComplete' }, + { id: 'documents', href: '/apply/upload-passport', statusField: 'documentsComplete' } + ] + } +} +``` + +Add matching locale strings: + +```yaml +# locales/en/fields.yml +taskList: + label: Your application + statuses: + default: Incomplete + completed: Completed + tasks: + personalDetails: + title: Personal details + documents: + title: Documents +``` + +In the corresponding steps configuration, use `noPost`, `checkJourney: false`, and `recordJourneyHistory` on the hub step, and `setValuesOnSave` on each section's final step: + +```js +// steps.js +module.exports = { + '/task-list': { + fields: ['taskList'], + noPost: true, + checkJourney: false, + recordJourneyHistory: true + }, + '/upload-passport': { + editable: true, + next: 'task-list', + setValuesOnSave: [{ key: 'documentsComplete', value: 'completed' }] + } +} +``` + +The `statuses` map keys correspond to session values stored via `setValuesOnSave`. When a task's `statusField` value matches a key in `statuses`, that status is rendered. The value in the map is a [GOV.UK tag colour](https://design-system.service.gov.uk/components/tag/) (e.g. `'blue'`, `'light-blue'`) or `false` for plain text with no tag. + ### Field parameters Most govuk-frontend parameters can be specified in the fields config, or supplied to the component directly. diff --git a/components/hmpo-all.njk b/components/hmpo-all.njk index e4f34c4..c0b1af3 100644 --- a/components/hmpo-all.njk +++ b/components/hmpo-all.njk @@ -14,6 +14,7 @@ {% include "hmpo-radios/macro.njk" %} {% include "hmpo-select/macro.njk" %} {% include "hmpo-submit/macro.njk" %} +{% include "hmpo-task-list/macro.njk" %} {% include "hmpo-text/macro.njk" %} {% include "hmpo-textarea/macro.njk" %} {% include "hmpo-warning-text/macro.njk" %} diff --git a/components/hmpo-field/macro.njk b/components/hmpo-field/macro.njk index a908260..394a7f1 100644 --- a/components/hmpo-field/macro.njk +++ b/components/hmpo-field/macro.njk @@ -38,6 +38,9 @@ {%- elif field.type == "wordcount"%} {% from "../hmpo-word-count/macro.njk" import hmpoWordCount %} {% set component = hmpoWordCount %} + {%- elif field.type == "task-list" %} + {% from "../hmpo-task-list/macro.njk" import hmpoTaskList %} + {{ hmpoTaskList(ctx, field, fieldName) }} {%- endif %} {%- if component %} diff --git a/components/hmpo-task-list/macro.njk b/components/hmpo-task-list/macro.njk new file mode 100644 index 0000000..b820b4f --- /dev/null +++ b/components/hmpo-task-list/macro.njk @@ -0,0 +1,44 @@ + +{% macro hmpoTaskList(ctx, params, fieldName) %} + {%- set translate = ctx("translate") %} + {%- set values = ctx("values") or {} %} + + {%- set labelText = translate("fields." + fieldName + ".label", { default: null }) if fieldName else null %} + {%- if labelText %} +

{{ labelText }}

+ {%- endif %} + + {%- set taskItems = [] %} + {%- for task in params.tasks or [] %} + {%- set statusValue = values[task.statusField] %} + {%- set statusKey = statusValue if (statusValue and statusValue in params.statuses) else 'default' %} + {%- set statusColour = params.statuses[statusKey] %} + {%- set statusText = translate("fields." + fieldName + ".statuses." + statusKey) %} + + {%- if statusColour %} + {%- set taskStatus = { tag: { text: statusText, classes: "govuk-tag--" + statusColour } } %} + {%- else %} + {%- set taskStatus = { text: statusText } %} + {%- endif %} + + {%- set titleKey = "fields." + fieldName + ".tasks." + task.id + ".title" %} + {%- set translatedTitle = translate(titleKey) %} + {%- set taskItem = { + title: { + text: translatedTitle + }, + href: task.href, + status: taskStatus + } %} + + {%- set taskItems = taskItems.concat([taskItem]) %} + {%- endfor %} + + {%- from "govuk/components/task-list/macro.njk" import govukTaskList %} + {{ govukTaskList({ + idPrefix: fieldName | default("task-list"), + items: taskItems, + classes: params.classes, + attributes: params.attributes + }) }} +{% endmacro %} diff --git a/components/hmpo-task-list/spec.macro.js b/components/hmpo-task-list/spec.macro.js new file mode 100644 index 0000000..5aa1dc5 --- /dev/null +++ b/components/hmpo-task-list/spec.macro.js @@ -0,0 +1,103 @@ +'use strict'; + +describe('hmpoTaskList', () => { + let locals, tasks, statuses; + + const renderTask = (params, fieldName = 'taskList', context = locals) => { + return render.withLocale({ + string: '{% from "hmpo-task-list/macro.njk" import hmpoTaskList %}{{ hmpoTaskList(ctx, params, "' + fieldName + '") }}', + ctx: true + }, Object.assign({}, context, { params })); + }; + + beforeEach(() => { + locals = { values: {} }; + statuses = { + 'default': 'blue', + completed: false + }; + tasks = [ + { id: 'taskOne', href: '/task-one', statusField: 'taskOneStatus' }, + { id: 'taskTwo', href: '/task-two', statusField: 'taskTwoStatus' } + ]; + }); + + it('renders a task list with items', () => { + const $ = renderTask({ tasks, statuses }); + expect($('.govuk-task-list__item').length).to.equal(2); + }); + + it('renders task titles from locale', () => { + const $ = renderTask({ tasks: [tasks[0]], statuses }); + expect($('.govuk-task-list__link').text().trim()).to.equal('Task one'); + }); + + it('renders task href links', () => { + const $ = renderTask({ tasks: [tasks[0]], statuses }); + expect($('.govuk-task-list__link').attr('href')).to.equal('/task-one'); + }); + + it('renders default status as a tag when statusField value is not set', () => { + const $ = renderTask({ tasks: [tasks[0]], statuses }); + const $tag = $('.govuk-tag'); + expect($tag.text().trim()).to.equal('Incomplete'); + expect($tag.attr('class')).to.contain('govuk-tag--blue'); + }); + + it('renders completed status as plain text when statusField value matches', () => { + locals.values = { taskOneStatus: 'completed' }; + const $ = renderTask({ tasks: [tasks[0]], statuses }); + expect($('.govuk-task-list__status').text().trim()).to.equal('Completed'); + expect($('.govuk-tag').length).to.equal(0); + }); + + it('renders mixed statuses for multiple tasks', () => { + locals.values = { taskOneStatus: 'completed' }; + const $ = renderTask({ tasks, statuses }); + const $items = $('.govuk-task-list__item'); + expect($items.eq(0).find('.govuk-task-list__status').text().trim()).to.equal('Completed'); + expect($items.eq(1).find('.govuk-tag').text().trim()).to.equal('Incomplete'); + }); + + it('falls back to default when statusField value does not match any status key', () => { + locals.values = { taskOneStatus: 'unknownState' }; + const $ = renderTask({ tasks: [tasks[0]], statuses }); + const $tag = $('.govuk-tag'); + expect($tag.text().trim()).to.equal('Incomplete'); + expect($tag.attr('class')).to.contain('govuk-tag--blue'); + }); + + it('supports additional statuses like inProgress', () => { + statuses.inProgress = 'light-blue'; + locals.values = { taskOneStatus: 'inProgress' }; + const $ = renderTask({ tasks: [tasks[0]], statuses }); + const $tag = $('.govuk-tag'); + expect($tag.text().trim()).to.equal('In progress'); + expect($tag.attr('class')).to.contain('govuk-tag--light-blue'); + }); + + it('idPrefix is derived from fieldName', () => { + const $ = renderTask({ tasks: [tasks[0]], statuses }); + expect($('.govuk-task-list__status').attr('id')).to.equal('taskList-1-status'); + }); + + it('idPrefix defaults to task-list when no fieldName provided', () => { + const $ = render.withLocale({ component: 'hmpoTaskList', params: { tasks: [tasks[0]], statuses }, ctx: true }, locals); + expect($('.govuk-task-list__status').attr('id')).to.equal('task-list-1-status'); + }); + + it('renders a section heading from fieldName label key', () => { + const $ = renderTask({ tasks, statuses }); + expect($('h2').text().trim()).to.equal('Your application'); + }); + + it('does not render a section heading when no fieldName is provided', () => { + const $ = render.withLocale({ component: 'hmpoTaskList', params: { tasks, statuses }, ctx: true }, locals); + expect($('h2').length).to.equal(0); + }); + + it('renders an empty task list when tasks is not provided', () => { + const $ = renderTask({ statuses }); + expect($('.govuk-task-list__item').length).to.equal(0); + }); +}); diff --git a/components/spec.task-list-template.js b/components/spec.task-list-template.js new file mode 100644 index 0000000..ce4eb96 --- /dev/null +++ b/components/spec.task-list-template.js @@ -0,0 +1,47 @@ +'use strict'; + +describe('task list template', () => { + let locals; + + beforeEach(() => { + locals = { + options: { + route: '/task-list', + fields: { + taskList: { + type: 'task-list', + statuses: { + 'default': 'blue', + completed: false + }, + tasks: [ + { id: 'taskOne', href: '/task-one', statusField: 'taskOneStatus' } + ] + } + } + }, + values: {} + }; + }); + + it('renders without throwing', () => { + expect(() => { + render({ template: 'task-list-template.njk' }, locals); + }).to.not.throw(); + }); + + it('renders the task list', () => { + const $ = render({ template: 'task-list-template.njk' }, locals); + expect($('.govuk-task-list').length).to.equal(1); + }); + + it('renders the submit button', () => { + const $ = render({ template: 'task-list-template.njk' }, locals); + expect($('.govuk-button').length).to.equal(1); + }); + + it('does not render a form', () => { + const $ = render({ template: 'task-list-template.njk' }, locals); + expect($('form').length).to.equal(0); + }); +}); diff --git a/components/task-list-template.njk b/components/task-list-template.njk new file mode 100644 index 0000000..74e6e47 --- /dev/null +++ b/components/task-list-template.njk @@ -0,0 +1,16 @@ +{% extends "app-template.njk" %} + +{% from "hmpo-field/macro.njk" import hmpoField %} +{% from "hmpo-submit/macro.njk" import hmpoSubmit %} + +{% block mainContentBody %} + {{ super() }} + + {% for fieldName, field in options.fields %} + {{ hmpoField(ctx, fieldName) }} + {% endfor %} + + {% block submitButton %} + {{ hmpoSubmit(ctx) }} + {% endblock %} +{% endblock %} diff --git a/test/locale.json b/test/locale.json index 570af9a..8191c27 100644 --- a/test/locale.json +++ b/test/locale.json @@ -33,6 +33,22 @@ }, "legendtest": { "legend": "Legend text" + }, + "taskList": { + "label": "Your application", + "statuses": { + "default": "Incomplete", + "completed": "Completed", + "inProgress": "In progress" + }, + "tasks": { + "taskOne": { + "title": "Task one" + }, + "taskTwo": { + "title": "Task two" + } + } } } }