diff --git a/anet-dictionary.yml b/anet-dictionary.yml
index 83bf050b4d..98a7ac6c18 100644
--- a/anet-dictionary.yml
+++ b/anet-dictionary.yml
@@ -1125,7 +1125,7 @@ fields:
placeholder: the six character code
principal:
-
+ onDemandAssessmentExpirationDays: 108
person:
name: Afghan Partner
countries: [Afghanistan]
@@ -1220,6 +1220,45 @@ fields:
yes:
label: 👍
color: '#0000ff'
+ - recurrence: ondemand
+ questions:
+ assessmentDate:
+ type: date
+ label: Screening and vetting date
+ validations:
+ - type: required
+ params: [You must provide the assessment date]
+ expirationDate:
+ type: date
+ label: Expiration date
+ question1:
+ type: enum
+ label: Screening and vetting level
+ choices:
+ pass1:
+ label: Pass 1
+ color: '#c2ffb3'
+ pass2:
+ label: Pass 2
+ color: '#c2ffb3'
+ pass3:
+ label: Pass 3
+ color: '#c2ffb3'
+ fail1:
+ label: Fail 1
+ color: '#ff8279'
+ fail2:
+ label: Fail 2
+ color: '#ff8279'
+ fail3:
+ label: Fail 3
+ color: '#ff8279'
+ question2:
+ type: special_field
+ label: Screening and vetting comment
+ widget: richTextEditor
+ style:
+ height: 70px
# number of fields after Avatar in the left column for principals
# adjust this number if two columns are not balanced on the Person Page
numberOfFieldsInLeftColumn: 7
diff --git a/client/src/components/CustomDateInput.js b/client/src/components/CustomDateInput.js
index bd037f6d8c..e579d9de0f 100644
--- a/client/src/components/CustomDateInput.js
+++ b/client/src/components/CustomDateInput.js
@@ -44,7 +44,7 @@ const CustomDateInput = ({
: Settings.dateFormats.forms.input.date
const inputFormat = dateFormats[0]
const timePickerProps = !withTime
- ? {}
+ ? undefined
: {
precision: TimePrecision.MINUTE,
selectAllOnFocus: true
diff --git a/client/src/components/CustomFields.js b/client/src/components/CustomFields.js
index 0faf5f2c05..5fa6ba26f5 100644
--- a/client/src/components/CustomFields.js
+++ b/client/src/components/CustomFields.js
@@ -13,7 +13,9 @@ import Model, {
CUSTOM_FIELD_TYPE,
DEFAULT_CUSTOM_FIELDS_PARENT,
INVISIBLE_CUSTOM_FIELDS_FIELD,
- SENSITIVE_CUSTOM_FIELDS_PARENT
+ SENSITIVE_CUSTOM_FIELDS_PARENT,
+ ENTITY_ASSESSMENT_PARENT_FIELD,
+ ENTITY_ON_DEMAND_ASSESSMENT_DATE
} from "components/Model"
import RemoveButton from "components/RemoveButton"
import RichTextEditor from "components/RichTextEditor"
@@ -164,12 +166,14 @@ const ReadonlyTextField = fieldProps => {
}
const DateField = fieldProps => {
- const { name, withTime, ...otherFieldProps } = fieldProps
+ const { name, withTime, maxDate, ...otherFieldProps } = fieldProps
return (
}
+ widget={
+
+ }
{...otherFieldProps}
/>
)
@@ -959,10 +963,18 @@ const CustomField = ({
return {
formikProps
}
+ case CUSTOM_FIELD_TYPE.DATE:
+ return {
+ maxDate:
+ fieldName ===
+ `${ENTITY_ASSESSMENT_PARENT_FIELD}.${ENTITY_ON_DEMAND_ASSESSMENT_DATE}`
+ ? moment().toDate()
+ : undefined
+ }
default:
return {}
}
- }, [fieldConfig, formikProps, invisibleFields, type])
+ }, [fieldConfig, fieldName, formikProps, invisibleFields, type])
return FieldComponent ? (
{
+ if (assessmentDate) {
+ return schema.min(
+ assessmentDate,
+ `${
+ assessmentConfig.expirationDate.label
+ } must be later than ${moment(assessmentDate).format("DD-MM-YYYY")}`
+ )
+ }
+ }
+ )
+ }
+ /******************************************************************************************/
+
return yup.object().shape({
[parentFieldName]: assessmentSchemaShape
})
@@ -602,6 +623,29 @@ export default class Model {
)
}
+ /**
+ * Filters ondemand assessments inside the notes object and returns them sorted
+ * with respect to their assessmentDate.
+ * @returns {object}
+ */
+ getOndemandAssessments() {
+ const onDemandNotes = this.notes.filter(
+ a =>
+ a.type === "ASSESSMENT" &&
+ utils.parseJsonSafe(a.text).__recurrence === RECURRENCE_TYPE.ON_DEMAND
+ )
+ // Sort the notes before visualizing them inside of a Card.
+ const sortedOnDemandNotes = onDemandNotes.sort((a, b) => {
+ return (
+ new Date(
+ utils.parseJsonSafe(a.text)[ENTITY_ON_DEMAND_ASSESSMENT_DATE]
+ ) -
+ new Date(utils.parseJsonSafe(b.text)[ENTITY_ON_DEMAND_ASSESSMENT_DATE])
+ )
+ })
+ return sortedOnDemandNotes
+ }
+
static getInstantAssessmentsDetailsForEntities(
entities,
assessmentsParentField,
diff --git a/client/src/components/PeriodsNavigation.js b/client/src/components/PeriodsNavigation.js
index 6ab2995182..968ed2762d 100644
--- a/client/src/components/PeriodsNavigation.js
+++ b/client/src/components/PeriodsNavigation.js
@@ -4,22 +4,39 @@ import PropTypes from "prop-types"
import React from "react"
import { Button } from "react-bootstrap"
-const PeriodsNavigation = ({ offset, onChange }) => (
+const PeriodsNavigation = ({
+ offset,
+ onChange,
+ disabledLeft,
+ disabledRight
+}) => (
-
)
PeriodsNavigation.propTypes = {
offset: PropTypes.number,
- onChange: PropTypes.func.isRequired
+ onChange: PropTypes.func.isRequired,
+ disabledLeft: PropTypes.bool,
+ disabledRight: PropTypes.bool
}
PeriodsNavigation.defaultProps = {
- offset: 0
+ offset: 0,
+ disabledLeft: false,
+ disabledRight: false
}
export default PeriodsNavigation
diff --git a/client/src/components/assessments/AssessmentModal.js b/client/src/components/assessments/AssessmentModal.js
index 76d73799f1..8c50cd8e83 100644
--- a/client/src/components/assessments/AssessmentModal.js
+++ b/client/src/components/assessments/AssessmentModal.js
@@ -44,7 +44,7 @@ const AssessmentModal = ({
centered
show={showModal}
onHide={closeModal}
- style={{ zIndex: "1150" }}
+ style={{ zIndex: "1220" }}
>
- {assessmentsTypes.map(
- assessmentsType =>
- PERIOD_FACTORIES[assessmentsType] && (
-
+ PERIOD_FACTORIES[assessmentsType] ? (
+
+ ) : (
+ assessmentsType === RECURRENCE_TYPE.ON_DEMAND && (
+
)
+ )
)}
)
diff --git a/client/src/components/assessments/OndemandAssessments.js b/client/src/components/assessments/OndemandAssessments.js
new file mode 100644
index 0000000000..619ccd15e9
--- /dev/null
+++ b/client/src/components/assessments/OndemandAssessments.js
@@ -0,0 +1,404 @@
+import { gql } from "@apollo/client"
+import { Icon } from "@blueprintjs/core"
+import { IconNames } from "@blueprintjs/icons"
+import API from "api"
+import AssessmentModal from "components/assessments/AssessmentModal"
+import ConfirmDestructive from "components/ConfirmDestructive"
+import { ReadonlyCustomFields } from "components/CustomFields"
+import Fieldset from "components/Fieldset"
+import LinkTo from "components/LinkTo"
+import Model, {
+ NOTE_TYPE,
+ ENTITY_ON_DEMAND_ASSESSMENT_DATE,
+ ENTITY_ON_DEMAND_EXPIRATION_DATE
+} from "components/Model"
+import PeriodsNavigation from "components/PeriodsNavigation"
+import { Formik } from "formik"
+import moment from "moment"
+import { PeriodsDetailsPropType, RECURRENCE_TYPE } from "periodUtils"
+import PropTypes from "prop-types"
+import React, { useCallback, useEffect, useMemo, useState } from "react"
+import { Badge, Button, Card, Col, Row, Table } from "react-bootstrap"
+import { toast } from "react-toastify"
+import Settings from "settings"
+import utils from "utils"
+
+const GQL_DELETE_NOTE = gql`
+ mutation($uuid: String!) {
+ deleteNote(uuid: $uuid)
+ }
+`
+
+const OnDemandAssessments = ({
+ entity,
+ entityType,
+ style,
+ periodsDetails,
+ canAddAssessment,
+ onUpdateAssessment
+}) => {
+ /* recurrence has the value 'ondemand' for this specific assessment type and
+ numberOfPeriods is a property of the parent component (AssessmentResultsContainer)
+ which is used to determine how many columns should be displayed inside of the
+ ondemand assessment table. Recalculated according to the screensize. */
+ const { recurrence, numberOfPeriods } = periodsDetails
+ // Button text.
+ const addAssessmentLabel = `Make a new ${entity?.toString()} assessment`
+ const [showModal, setShowModal] = useState(false)
+ // Used to determine if the AssessmentModal is in edit mode or create mode.
+ const [editModeObject, setEditModeObject] = useState({
+ questionnaireResults: {},
+ uuid: ""
+ })
+ // 'assessmentConfig' has question set for ondemand assessments defined in the dictionary
+ // and 'assessmentYupSchema' used for this question set.
+ const {
+ assessmentConfig,
+ assessmentYupSchema
+ } = entity.getPeriodicAssessmentDetails(recurrence)
+
+ const filteredAssessmentConfig = Model.filterAssessmentConfig(
+ assessmentConfig,
+ entity
+ )
+ filteredAssessmentConfig[ENTITY_ON_DEMAND_EXPIRATION_DATE].helpText = `
+ If this field is left empty, the assessment will be valid for
+ ${Settings.fields.principal.onDemandAssessmentExpirationDays} days.
+ `
+
+ // Cards array updated before loading the page & after every save of ondemand assessment.
+ const assessmentCards = useMemo(() => {
+ const cards = []
+ const sortedOnDemandNotes = entity.getOndemandAssessments()
+ sortedOnDemandNotes.forEach((note, index) => {
+ const parentFieldName = `assessment-${note.uuid}`
+ const assessmentFieldsObject = utils.parseJsonSafe(note.text)
+ // Fill the 'expirationDate' field if it is empty
+ if (!assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE]) {
+ assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE] = moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_ASSESSMENT_DATE]
+ )
+ .add(
+ Settings.fields.principal.onDemandAssessmentExpirationDays,
+ "days"
+ )
+ .toDate()
+ .toISOString()
+ }
+ cards.push(
+ <>
+
+
+ {/* Only the last object in the sortedOnDemandNotes can be valid.
+ If the expiration date of the last object is older than NOW,
+ it is also expired. */}
+ {moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE]
+ ).isBefore(moment()) ? (
+ "Expired"
+ ) : index !== sortedOnDemandNotes.length - 1 ? (
+ "No longer valid"
+ ) : (
+ <>
+ Valid until{" "}
+ {moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE]
+ ).format("DD MMMM YYYY")}{" "}
+
+ {/* true flag in the diff function returns the precise days
+ between two dates, e.g., '1,4556545' days. 'ceil' function
+ from Math library is used to round it to the nearest greatest
+ integer so that user sees an integer as the number of days left */}
+ {Math.ceil(
+ moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE]
+ ).diff(moment(), "days", true)
+ )}{" "}
+ of{" "}
+ {moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_EXPIRATION_DATE]
+ ).diff(
+ moment(
+ assessmentFieldsObject[ENTITY_ON_DEMAND_ASSESSMENT_DATE]
+ ),
+ "days"
+ )}{" "}
+ days left
+
+ >
+ )}
+
+
+
+
+ >
+ )
+ })
+ return cards
+
+ function deleteNote(uuid) {
+ API.mutation(GQL_DELETE_NOTE, { uuid })
+ .then(() => {
+ onUpdateAssessment()
+ toast.success("Successfully deleted")
+ })
+ .catch(error => {
+ toast.error(error.message.split(":").pop())
+ })
+ }
+ }, [
+ entity,
+ assessmentConfig,
+ onUpdateAssessment,
+ canAddAssessment,
+ numberOfPeriods
+ ])
+ // Holds JSX element array (assessment cards).
+ const [onDemandAssessmentCards, setOnDemandAssessmentCards] = useState(
+ assessmentCards
+ )
+ /* Used for navigating when PeriodsNavigation buttons are pressed. Initial value
+ should show the valid card in the table to the user when the page is loaded. */
+ const [tableLocation, setTableLocation] = useState(
+ onDemandAssessmentCards.length >= numberOfPeriods
+ ? numberOfPeriods - onDemandAssessmentCards.length
+ : 0
+ )
+
+ /**
+ * Puts the top three elements of onDemandAssessmentCards into table columns. If the number
+ * of cards in the onDemandAssessmentCards array is smaller than 'numberOfPeriods', then
+ * empty columns are placed into the table.
+ * @param {number} numberOfPeriods How many columns should be displayed inside of the table.
+ * @returns {JSX.Element[]}
+ */
+ const createColumns = useCallback(
+ numberOfPeriods => {
+ const columns = []
+ for (let index = 0; index < numberOfPeriods; index++) {
+ columns.push(
+
{onDemandAssessmentCards[index - tableLocation]}
+ )
+ }
+ return columns
+ },
+ [onDemandAssessmentCards, tableLocation]
+ )
+
+ // Trigger re-render of cards after a save of ondemand assessments.
+ useEffect(() => {
+ setOnDemandAssessmentCards(assessmentCards)
+ }, [assessmentCards])
+
+ if (recurrence !== RECURRENCE_TYPE.ON_DEMAND) {
+ console.error(
+ `Recurrence type is not ${RECURRENCE_TYPE.ON_DEMAND}. Component will not be rendered!`
+ )
+ } else {
+ return (
+
+
+
+ {/* If 'uuid' has a value of empty string, it means AssessmentModal is in
+ create mode. If it has the value of the note uuid, then the AssessmentModal
+ is in edit mode.
+ If the 'assessment' has an empty object, it means AsessmentModal is in
+ create mode. If it has the ondemand assessment values, AssessmentModal
+ is in edit mode.
+ The above conditions should be satisfied at the same time. */}
+ 0
+ ? editModeObject.uuid
+ : ""
+ }}
+ assessment={editModeObject.questionnaireResults}
+ title={`Assessment for ${entity.toString()}`}
+ assessmentYupSchema={assessmentYupSchema}
+ recurrence={recurrence}
+ assessmentPeriod={{
+ start: moment() // This prop is required but has no impact on this component.
+ }}
+ assessmentConfig={filteredAssessmentConfig}
+ onSuccess={() => {
+ setShowModal(false)
+ onUpdateAssessment()
+ /* Set the table position in a way that the user always sees the
+ latest card in the table after addition of an ondemand assessment.
+ Also make sure editing does not shift. */
+ if (Object.keys(editModeObject.questionnaireResults).length === 0) {
+ setTableLocation(
+ onDemandAssessmentCards.length >= numberOfPeriods
+ ? numberOfPeriods - onDemandAssessmentCards.length - 1
+ : 0
+ )
+ }
+ setEditModeObject({ questionnaireResults: {}, uuid: "" })
+ }}
+ onCancel={() => {
+ setShowModal(false)
+ setEditModeObject({ questionnaireResults: {}, uuid: "" })
+ }}
+ />
+
+ )
+}
diff --git a/client/stories/vettingAndScreening.stories.mdx b/client/stories/vettingAndScreening.stories.mdx
new file mode 100644
index 0000000000..bba149311c
--- /dev/null
+++ b/client/stories/vettingAndScreening.stories.mdx
@@ -0,0 +1,56 @@
+import { Story, Meta, Canvas } from "@storybook/addon-docs"
+import { VettingAndScreening } from "./VettingAndScreening.stories.js"
+
+
+
+# Vetting & Screening
+
+In order to add a new vetting and screening assessment, simply find the
+**Assessment results - on demand** section in the counterpart (principal)
+page and press Make a new assessment button.
+
+A modal will pop up afterwards asking you to fill in the vetting and screening
+form. Below, there is a live component filled in with multiple assessments. The component used in ANET is identical to this example. However, initially there will be no assessments in the list (be aware that addition, editing & deletion operations do not result in any change in this platform. Data shown below is previously prepared and **can not** be modified).
+
+
+
+
+
+## Validity of Assessments & Coloring
+
+Please pay attention to the `Screening and vetting date` and `Expiration date` fields when form modal is visible. They are
+essential for calculating the expiration of an assessment. The assessment cards are sorted according to the `Screening and vetting date` information and the assessment with the most recent `Screening and vetting date` information is placed in right-most-order in the list.
+
+Expiration of the assessments are calculated according to the `Expiration date` field. As the help text suggests below the field; in case you left the field empty, the default duration of 108 days is going to be applied which is configurable later if needed. Furthermore, the expiration date could be entered and the duration of the assessment will be calculated accordingly. In addition, the expiration date is excluded and assessments with an expiration date pointing to the current date are going to be considered as expired.
+
+There can only be **one** valid assessment at a time. The validity of an assessment is shown on top of the corresponding assessment card. The most recent assessment is always shown right most and it is hightlighted by **Valid until [date]** text if the assessment is still valid or by **Expired** text if it is expired.
+
+There are two conditions for an assessment to be invalid:
+
+- If a new assessment is added before the current one expires; in this case **No longer valid** status will be displayed.
+- If the current date is later than `Expiration date`; in this case **Expired** status will be displayed (note that the text color would be red if there are no recent assessments).
+
+## Navigation
+
+_previous period_ and _next period_ buttons can be used to navigate between assessment cards. _next period_ button takes you to recent cards and _previous period_ button moves you in the opposite direction. If there are no cards to be shown in either direction, buttons become disabled.
+
+Addition and deletion operations results in shifting the cards to make sure user sees the most recent assessment in the list. Edition operation does not trigger shifting of cards.
+
+## Assessment Card Layout
+
+Light grey area above assessment cards are called card header and there are 4 aspects to pay attention:
+
+1. The date which the assessment is made.
+2. ANET link which indicates who did the assessment.
+3. Edit button
+4. Delete button
+
+Below the light grey area called card body, you can see the values of the corresponding assessment.
+
+## Final Remarks
+
+Deletion of an assessments might change the validity of the remaining cards
+
+Overriding the `Screening and vetting date` or `Expiration date` fields while editing an assessment might result in changing the validity of the assessments.
+
+Default number of assessments visible in the list is **3** but for smaller screen sizes, less number of assessments are shown in the list at once.
diff --git a/src/main/resources/anet-schema.yml b/src/main/resources/anet-schema.yml
index ccedc013b7..29ac1b607d 100644
--- a/src/main/resources/anet-schema.yml
+++ b/src/main/resources/anet-schema.yml
@@ -249,7 +249,7 @@ $defs:
recurrence:
title: recurrence of an assessment
type: string
- enum: [once, daily, weekly, biweekly, semimonthly, monthly, quarterly, semiannually, annually]
+ enum: [once, daily, weekly, biweekly, semimonthly, monthly, quarterly, semiannually, annually, ondemand]
default: once
relatedObjectType:
title: object type context in which the assessment will be made
@@ -261,6 +261,21 @@ $defs:
type: object
additionalProperties:
"$ref": "#/$defs/customField"
+ dependencies:
+ recurrence:
+ oneOf:
+ - properties:
+ recurrence:
+ enum: [once, daily, weekly, biweekly, semimonthly, monthly, quarterly, semiannually, annually]
+ - properties:
+ recurrence:
+ enum: [ondemand]
+ questions:
+ assessmentDate:
+ type: date
+ expirationDate:
+ type: date
+ required: [assessmentDate, expirationDate]
# definition for custom sensitive information
customSensitiveInformation:
@@ -747,7 +762,9 @@ properties:
additionalProperties: false
required: [person, position, org]
properties:
-
+ onDemandAssessmentExpirationDays:
+ type: number
+ additionalProperties: false
person:
type: object
additionalProperties: false