diff --git a/components/CommonActionForm.vue b/components/CommonActionForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..8d76634794b94b52c1c7544b5d5497eabbde0bb2 --- /dev/null +++ b/components/CommonActionForm.vue @@ -0,0 +1,240 @@ +<!-- +Web client of the Sensor Management System software developed within the +Helmholtz DataHub Initiative by GFZ and UFZ. + +Copyright (C) 2020, 2021 +- Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) +- Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) +- Helmholtz Centre Potsdam - GFZ German Research Centre for + Geosciences (GFZ, https://www.gfz-potsdam.de) + +Parts of this program were developed within the context of the +following publicly funded projects or measures: +- Helmholtz Earth and Environment DataHub + (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + +Licensed under the HEESIL, Version 1.0 or - as soon they will be +approved by the "Community" - subsequent versions of the HEESIL +(the "Licence"). + +You may not use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: +https://gitext.gfz-potsdam.de/software/heesil + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. +--> +<template> + <v-container> + <v-row> + <v-col cols="12" md="12"> + <v-textarea + v-model="description" + label="Description" + rows="3" + /> + </v-col> + </v-row> + <v-row> + <v-col cols="12" md="6"> + <v-form + ref="contactForm" + v-model="contactIsValid" + @submit.prevent + > + <v-autocomplete + v-model="contact" + :items="contacts" + label="Contact" + clearable + required + :item-text="(x) => x.toString()" + :item-value="(x) => x" + :rules="rules" + /> + </v-form> + </v-col> + <v-col cols="12" md="1" align-self="center"> + <v-btn small @click="selectCurrentUserAsContact"> + {{ labelForSelectMeButton }} + </v-btn> + </v-col> + </v-row> + <v-row> + <v-col> + <v-select + v-model="actionAttachments" + multiple + chips + clearable + deletable-chips + label="Attachments" + prepend-icon="mdi-paperclip" + no-data-text="There are no attachments for this device" + :items="attachments" + :item-text="(x) => x.label" + :item-value="(x) => x" + :disabled="!attachments.length" + /> + </v-col> + </v-row> + </v-container> +</template> + +<script lang="ts"> +/** + * @file provides a component for a set of common form fields for actions + * @author <marc.hanisch@gfz-potsdam.de> + */ +import { Component, Prop, Vue } from 'nuxt-property-decorator' + +import { Attachment } from '@/models/Attachment' +import { Contact } from '@/models/Contact' +import { GenericAction } from '@/models/GenericAction' + +/** + * A class component for a set of common form fields for actions + * @extends Vue + */ +@Component +// @ts-ignore +export default class CommonActionForm extends Vue { + private contacts: Contact[] = [] + private readonly labelForSelectMeButton = 'Add current user' + private contactIsValid = true + + /** + * a GenericAction + */ + @Prop({ + default: new GenericAction(), + required: true, + type: Object + }) + // @ts-ignore + readonly value!: GenericAction + + /** + * a list of available attachments + */ + @Prop({ + default: () => [], + required: false, + type: Array + }) + // @ts-ignore + readonly attachments!: Attachment[] + + /** + * rules + */ + @Prop({ + default: () => [], + required: false, + type: Array + }) + // @ts-ignore + readonly rules!: ((v: any) => boolean | string)[] + + async fetch () { + try { + this.contacts = await this.$api.contacts.findAll() + } catch (error) { + this.$store.commit('snackbar/setError', 'Failed to fetch contacts') + } + } + + get description (): string { + return this.value.description + } + + /** + * sets the new description + * + * @param {string} value - the description to set + * @fires CommonActionForm#input + */ + set description (value: string) { + const actionCopy = GenericAction.createFromObject(this.value) + actionCopy.description = value + /** + * descriptionChange event + * @event CommonActionForm#input + * @type {string} + */ + this.$emit('input', actionCopy) + } + + get contact (): Contact | null { + return this.value.contact + } + + /** + * sets the new contact + * + * @param {Contact | null} value - the contact to set + * @fires CommonActionForm#input + */ + set contact (value: Contact | null) { + const actionCopy = GenericAction.createFromObject(this.value) + actionCopy.contact = value || null + /** + * contactChange event + * @event CommonActionForm#input + * @type {Contact} + */ + this.$emit('input', actionCopy) + } + + get actionAttachments (): Attachment[] { + return this.value.attachments + } + + /** + * sets the new list of attachments + * + * @param {Attachment[]} value - the list of attachments to set + * @fires CommonActionForm#input + */ + set actionAttachments (value: Attachment[]) { + const actionCopy = GenericAction.createFromObject(this.value) + actionCopy.attachments = value + /** + * attachments event + * @event CommonActionForm#input + * @type {Attachment[]} + */ + this.$emit('input', actionCopy) + } + + /** + * selects the current (loggined) user from the list of users and adds the + * user to the action + * + */ + selectCurrentUserAsContact () { + const currentUserMail = this.$store.getters['oidc/userEmail'] + if (currentUserMail) { + const userIndex = this.contacts.findIndex(c => c.email === currentUserMail) + if (userIndex > -1) { + this.contact = this.contacts[userIndex] + return + } + } + this.$store.commit('snackbar/setError', 'No contact found with your data') + } + + /** + * returns whether the form is valid based on the defined rules + * + * @return {boolean} whether the form is valid or not + */ + isValid (): boolean { + return (this.$refs.contactForm as Vue & { validate: () => boolean }).validate() + } +} +</script> diff --git a/components/DatePicker.vue b/components/DatePicker.vue index a423ab6d05e2c2dfb70ee7f5d998534a5de7032e..843b1d4dcaab876c651cb99e48f4e4b1e21f5269 100644 --- a/components/DatePicker.vue +++ b/components/DatePicker.vue @@ -39,7 +39,7 @@ permissions and limitations under the Licence. offset-y min-width="290px" > - <template v-slot:activator="{ on, attrs }"> + <template #activator="{ on, attrs }"> <v-text-field :value="getDate()" :rules="rules" diff --git a/components/GenericActionCard.vue b/components/GenericActionCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..e895c12680d81e9dd00a87f89430581ef19fb895 --- /dev/null +++ b/components/GenericActionCard.vue @@ -0,0 +1,136 @@ +<!-- +Web client of the Sensor Management System software developed within the +Helmholtz DataHub Initiative by GFZ and UFZ. + +Copyright (C) 2020, 2021 +- Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) +- Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) +- Helmholtz Centre Potsdam - GFZ German Research Centre for + Geosciences (GFZ, https://www.gfz-potsdam.de) + +Parts of this program were developed within the context of the +following publicly funded projects or measures: +- Helmholtz Earth and Environment DataHub + (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + +Licensed under the HEESIL, Version 1.0 or - as soon they will be +approved by the "Community" - subsequent versions of the HEESIL +(the "Licence"). + +You may not use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: +https://gitext.gfz-potsdam.de/software/heesil + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. +--> +<template> + <v-card> + <v-card-subtitle class="pb-0"> + <v-row no-gutters> + <v-col> + {{ value.beginDate | toUtcDate }} - {{ value.endDate | toUtcDate }} + </v-col> + <v-col + align-self="end" + class="text-right" + > + <slot name="menu" /> + </v-col> + </v-row> + </v-card-subtitle> + <v-card-title class="pt-0"> + {{ value.actionTypeName }} + </v-card-title> + <v-card-subtitle class="pb-1"> + <v-row + no-gutters + > + <v-col> + {{ value.contact.toString() }} + </v-col> + <v-col + align-self="end" + class="text-right" + > + <slot name="actions" /> + <v-btn + icon + @click.stop.prevent="toggleVisibility()" + > + <v-icon>{{ isVisible() ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> + </v-btn> + </v-col> + </v-row> + </v-card-subtitle> + <v-expand-transition> + <div + v-show="isVisible(value.id)" + > + <v-card-text + class="grey lighten-5 text--primary pt-2" + > + <label>Description</label> + {{ value.description }} + </v-card-text> + </div> + </v-expand-transition> + </v-card> +</template> + +<script lang="ts"> +/** + * @file provides a component for a Generic Device Actions card + * @author <marc.hanisch@gfz-potsdam.de> + */ +import { Component, Prop, Vue } from 'nuxt-property-decorator' + +import { dateToDateTimeString } from '@/utils/dateHelper' +import { GenericAction } from '@/models/GenericAction' + +/** + * A class component for Generic Device Action card + * @extends Vue + */ +@Component({ + filters: { + toUtcDate: dateToDateTimeString + } +}) +// @ts-ignore +export default class GenericActionCard extends Vue { + private showDetails: boolean = false + + /** + * a GenericAction + */ + @Prop({ + default: () => new GenericAction(), + required: true, + type: Object + }) + // @ts-ignore + readonly value!: GenericAction + + /** + * whether the card expansion is shown or not + * + * @return {boolean} whether the card expansion is shown or not + */ + isVisible (): boolean { + return this.showDetails + } + + /** + * toggles the shown state of the card expansion + * + */ + toggleVisibility (): void { + this.showDetails = !this.showDetails + } +} +</script> diff --git a/components/GenericActionForm.vue b/components/GenericActionForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..3973e7cce4df676ff4d91ab20ef9ac8506c919ae --- /dev/null +++ b/components/GenericActionForm.vue @@ -0,0 +1,248 @@ +<!-- +Web client of the Sensor Management System software developed within the +Helmholtz DataHub Initiative by GFZ and UFZ. + +Copyright (C) 2020, 2021 +- Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) +- Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) +- Helmholtz Centre Potsdam - GFZ German Research Centre for + Geosciences (GFZ, https://www.gfz-potsdam.de) + +Parts of this program were developed within the context of the +following publicly funded projects or measures: +- Helmholtz Earth and Environment DataHub + (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + +Licensed under the HEESIL, Version 1.0 or - as soon they will be +approved by the "Community" - subsequent versions of the HEESIL +(the "Licence"). + +You may not use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: +https://gitext.gfz-potsdam.de/software/heesil + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. +--> +<template> + <v-container> + <v-form + ref="datesForm" + v-model="datesAreValid" + @submit.prevent + > + <v-row> + <v-col cols="12" md="6"> + <DatePicker + :value="actionCopy.beginDate" + label="Start date" + :rules="[rules.startDate, rules.startDateNotNull]" + @input="setStartDateAndValidate" + /> + </v-col> + <v-col cols="12" md="6"> + <DatePicker + :value="actionCopy.endDate" + label="End date" + :rules="[rules.endDate]" + @input="setEndDateAndValidate" + /> + </v-col> + </v-row> + </v-form> + <CommonActionForm + ref="commonForm" + v-model="action" + :attachments="attachments" + :rules="[rules.contactNotNull]" + /> + </v-container> +</template> + +<script lang="ts"> +/** + * @file provides a component for a Generic Device Actions form + * @author <marc.hanisch@gfz-potsdam.de> + */ +import { Component, Prop, Vue, Watch } from 'nuxt-property-decorator' + +import { DateTime } from 'luxon' +import { stringToDate } from '@/utils/dateHelper' + +import { Attachment } from '@/models/Attachment' +import { GenericAction } from '@/models/GenericAction' + +import CommonActionForm from '@/components/CommonActionForm.vue' +import DatePicker from '@/components/DatePicker.vue' + +/** + * A class component for a form for Generic Device Actions + * @extends Vue + */ +@Component({ + components: { + CommonActionForm, + DatePicker + } +}) +// @ts-ignore +export default class GenericActionForm extends Vue { + private actionCopy: GenericAction = new GenericAction() + private datesAreValid: boolean = true + private rules: Object = { + startDate: this.validateInputForStartDate, + startDateNotNull: this.mustBeProvided('Start date'), + endDate: this.validateInputForEndDate, + contactNotNull: this.mustBeProvided('Contact') + } + + /** + * a GenericAction + */ + @Prop({ + default: () => new GenericAction(), + required: true, + type: Object + }) + // @ts-ignore + readonly value!: GenericAction + + /** + * a list of available attachments + */ + @Prop({ + default: () => [], + required: false, + type: Array + }) + // @ts-ignore + readonly attachments!: Attachment[] + + created () { + // create a copy of the original value on which all operations will be applied + this.createActionCopy(this.value) + } + + get action (): GenericAction { + return this.actionCopy + } + + set action (value: GenericAction) { + this.$emit('input', value) + } + + /** + * sets the start date and validates start- and enddate + * + * @param {DateTime | null} aDate - the start date + */ + setStartDateAndValidate (aDate: DateTime | null) { + this.actionCopy.beginDate = aDate + if (this.actionCopy.endDate !== null) { + this.checkValidationOfDates() + } + this.$emit('input', this.actionCopy) + } + + /** + * sets the end date and validates start- and enddate + * + * @param {DateTime | null} aDate - the end date + */ + setEndDateAndValidate (aDate: DateTime | null) { + this.actionCopy.endDate = aDate + if (this.actionCopy.beginDate !== null) { + this.checkValidationOfDates() + } + this.$emit('input', this.actionCopy) + } + + /** + * validates the form based on its rules + * + */ + checkValidationOfDates () { + return (this.$refs.datesForm as Vue & { validate: () => boolean }).validate() + } + + /** + * a rule to validate the start date + * + * @param {string} v - a string representation of the date as supplied by the datepicker component + * @return {boolean | string} whether the date is valid or an error message + */ + validateInputForStartDate (v: string): boolean | string { + // NOTE: as the internals of the DatePicker component work with strings, + // the validation functions should expect strings, too + if (v === null || v === '') { + return true + } + if (!this.actionCopy.endDate) { + return true + } + if (stringToDate(v) <= this.actionCopy.endDate) { + return true + } + return 'Start date must not be after end date' + } + + /** + * a rule to validate the end date + * + * @param {string} v - a string representation of the date as supplied by the datepicker component + * @return {boolean | string} whether the date is valid or an error message + */ + validateInputForEndDate (v: string): boolean | string { + // NOTE: as the internals of the DatePicker component work with strings, + // the validation functions should expect strings, too + if (v === null || v === '') { + return true + } + if (!this.actionCopy.beginDate) { + return true + } + if (stringToDate(v) >= this.actionCopy.beginDate) { + return true + } + return 'End date must not be before start date' + } + + /** + * a rule to check that an field is non-empty + * + * @param {string} fieldname - the (human readable) label of the field + * @return {(v: any) => boolean | string} a function that checks whether the field is valid or an error message + */ + mustBeProvided (fieldname: string): (v: any) => boolean | string { + const innerFunc: (v: any) => boolean | string = function (v: any) { + if (v == null || v === '') { + return fieldname + ' must be provided' + } + return true + } + return innerFunc + } + + /** + * checks if the form is valid + * + */ + isValid (): boolean { + return this.checkValidationOfDates() && (this.$refs.commonForm as Vue & { isValid: () => boolean }).isValid() + } + + createActionCopy (action: GenericAction): void { + this.actionCopy = GenericAction.createFromObject(action) + } + + @Watch('value', { immediate: true, deep: true }) + // @ts-ignore + onValueChanged (val: GenericAction) { + this.createActionCopy(val) + } +} +</script> diff --git a/modelUtils/Compareables.ts b/modelUtils/Compareables.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbd815b9ab2209127103ec9587adc93525bd302a --- /dev/null +++ b/modelUtils/Compareables.ts @@ -0,0 +1,31 @@ +import { DateTime } from 'luxon' + +export interface IComparator<T> { + compare (a: T, b: T): number +} + +export interface IDateCompareable { + date: DateTime | null +} + +export function isDateCompareable (i: any): i is IDateCompareable { + if (i && typeof i === 'object' && Object.prototype.hasOwnProperty.call(i, 'date') && DateTime.isDateTime(i.date)) { + return true + } + return false +} + +export class DateComparator implements IComparator<IDateCompareable> { + compare (a: IDateCompareable, b: IDateCompareable): number { + if (!a.date && !b.date) { + return 0 + } + if (!a.date || (b.date && a.date < b.date)) { + return -1 + } + if (!b.date || (a.date && a.date > b.date)) { + return 1 + } + return 0 + } +} diff --git a/models/Action.ts b/models/Action.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d90f32857ac2ac4a11f33edb5331d48c5c42de6 --- /dev/null +++ b/models/Action.ts @@ -0,0 +1,38 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { Contact } from '@/models/Contact' + +export interface IAction { + id: string | null + description: string + contact: Contact | null +} diff --git a/models/ActionType.ts b/models/ActionType.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce53694850b0e9fee426f8b898cc909f46da9354 --- /dev/null +++ b/models/ActionType.ts @@ -0,0 +1,100 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +export interface IActionType { + id: string + name: string + uri: string + definition: string +} + +export class ActionType implements IActionType { + private _id: string = '' + private _name: string = '' + private _uri: string = '' + private _definition: string = '' + + get id (): string { + return this._id + } + + set id (newId: string) { + this._id = newId + } + + get name (): string { + return this._name + } + + set name (newName: string) { + this._name = newName + } + + get uri (): string { + return this._uri + } + + set uri (newUri: string) { + this._uri = newUri + } + + get definition (): string { + return this._definition + } + + set definition (newdefinition: string) { + this._definition = newdefinition + } + + toString (): string { + return this._name + } + + static createWithData (id: string, name: string, uri: string, definition: string): ActionType { + const result = new ActionType() + result.id = id + result.name = name + result.uri = uri + result.definition = definition + return result + } + + static createFromObject (someObject: IActionType): ActionType { + const newObject = new ActionType() + + newObject.id = someObject.id + newObject.name = someObject.name + newObject.uri = someObject.uri + newObject.definition = someObject.definition + + return newObject + } +} diff --git a/models/GenericAction.ts b/models/GenericAction.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d144df1fa2bf1e8c0560974cdf92adc2c1074fe --- /dev/null +++ b/models/GenericAction.ts @@ -0,0 +1,158 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { DateTime } from 'luxon' +import { Attachment } from '@/models/Attachment' +import { Contact } from '@/models/Contact' +import { IAction } from '@/models/Action' +import { IDateCompareable } from '@/modelUtils/Compareables' + +export interface IGenericAction extends IAction { + actionTypeName: string + actionTypeUrl: string + beginDate: DateTime | null + endDate: DateTime | null + contact: Contact | null + attachments: Attachment[] +} + +export class GenericAction implements IGenericAction, IDateCompareable { + private _id: string | null = null + private _description: string = '' + private _actionTypeName: string = '' + private _actionTypeUrl: string = '' + private _beginDate: DateTime | null = null + private _endDate: DateTime | null = null + private _contact: Contact | null = null + private _attachments: Attachment[] = [] + + /** + * returns an empty instance + * + * @static + * @return {GenericAction} an empty instance + */ + static createEmpty (): GenericAction { + return new GenericAction() + } + + /** + * creates an instance from an existing IGenericAction-like object + * + * @static + * @param {IGenericAction} someObject - an IGenericAction like object + * @return {GenericAction} a cloned instance of the original object + */ + static createFromObject (someObject: IGenericAction) : GenericAction { + const action = new GenericAction() + action.id = someObject.id + action.description = someObject.description + action.actionTypeName = someObject.actionTypeName + action.actionTypeUrl = someObject.actionTypeUrl + action.beginDate = someObject.beginDate ? someObject.beginDate : null + action.endDate = someObject.endDate ? someObject.endDate : null + action.contact = someObject.contact ? Contact.createFromObject(someObject.contact) : null + action.attachments = someObject.attachments.map(i => Attachment.createFromObject(i)) + return action + } + + get id (): string | null { + return this._id + } + + set id (id: string | null) { + this._id = id + } + + get description (): string { + return this._description + } + + set description (description: string) { + this._description = description + } + + get actionTypeUrl (): string { + return this._actionTypeUrl + } + + set actionTypeUrl (actionTypeUrl: string) { + this._actionTypeUrl = actionTypeUrl + } + + get actionTypeName (): string { + return this._actionTypeName + } + + set actionTypeName (actionTypeName: string) { + this._actionTypeName = actionTypeName + } + + get beginDate (): DateTime | null { + return this._beginDate + } + + set beginDate (date: DateTime | null) { + this._beginDate = date + } + + get endDate (): DateTime | null { + return this._endDate + } + + set endDate (date: DateTime | null) { + this._endDate = date + } + + get contact (): Contact | null { + return this._contact + } + + set contact (contact: Contact | null) { + this._contact = contact + } + + get attachments (): Attachment[] { + return this._attachments + } + + set attachments (attachments: Attachment[]) { + this._attachments = attachments + } + + get isGenericAction (): boolean { + return true + } + + get date (): DateTime | null { + return this.beginDate + } +} diff --git a/pages/devices/_deviceId/actions.vue b/pages/devices/_deviceId/actions.vue index 614eefe9b58e984feeec2dccae18417c55f94741..da0005ceaac806a893b78490893989b3b9b257a5 100644 --- a/pages/devices/_deviceId/actions.vue +++ b/pages/devices/_deviceId/actions.vue @@ -37,74 +37,107 @@ permissions and limitations under the Licence. <v-card-actions> <v-spacer /> <v-btn - v-if="isLoggedIn && !(isAddActionPage)" + v-if="isLoggedIn && !(isAddActionPage || isEditActionPage)" color="primary" small - :disabled="isEditActionPage" :to="'/devices/' + deviceId + '/actions/new'" > Add Action </v-btn> </v-card-actions> - <template v-if="isAddActionPage"> - <NuxtChild /> + <template + v-if="isAddActionPage" + > + <NuxtChild + @input="$fetch" + @showsave="showsave" + /> + </template> + <template + v-else-if="isEditActionPage" + > + <!-- the currently edited action is passed as a property to the + `edit.vue` page to avoid reloading of the action --> + <NuxtChild + :value="editedAction" + @input="$fetch" + @showsave="showsave" + /> </template> <template v-else> <v-timeline dense> <v-timeline-item - v-for="action in actions" - :key="action.getId()" + v-for="(action, index) in actions" + :key="getActionTypeIterationKey(action)" :color="action.getColor()" class="mb-4" small > - <template v-if="action.isGenericDeviceAction"> - <v-card> - <v-card-subtitle class="pb-0"> - {{ action.beginDate | toUtcDate }} - {{ action.endDate | toUtcDate }} - </v-card-subtitle> - <v-card-title class="py-0"> - <v-row - no-gutters - > - <v-col - cols="11" + <GenericActionCard + v-if="action.isGenericAction" + v-model="actions[index]" + > + <template #menu> + <v-menu + close-on-click + close-on-content-click + offset-x + left + z-index="999" + > + <template #activator="{ on }"> + <v-btn + data-role="property-menu" + icon + small + v-on="on" > - {{ action.actionTypeName }} - </v-col> - <v-col - align-self="end" - class="text-right" - > - <v-btn - icon - @click.stop.prevent="showActionItem(action.getId())" + <v-icon + dense + small > - <v-icon>{{ isActionItemShown(action.getId()) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> - </v-btn> - </v-col> - </v-row> - </v-card-title> - <v-expand-transition> - <div - v-show="isActionItemShown(action.getId())" - > - <v-card-subtitle - class="pt-0" + mdi-dots-vertical + </v-icon> + </v-btn> + </template> + + <v-list> + <v-list-item + :disabled="!isLoggedIn" + dense + @click="showDeleteDialog(action.id)" > - {{ action.contact.toString() }} - </v-card-subtitle> - <v-card-text - class="grey lighten-5 text--primary pt-2" - > - <label>Description</label> - {{ action.description }} - </v-card-text> - </div> - </v-expand-transition> - </v-card> - </template> - <template v-if="action.isUpdateAction"> + <v-list-item-content> + <v-list-item-title + :class="isLoggedIn ? 'red--text' : 'grey--text'" + > + <v-icon + left + small + :color="isLoggedIn ? 'red' : 'grey'" + > + mdi-delete + </v-icon> + Delete + </v-list-item-title> + </v-list-item-content> + </v-list-item> + </v-list> + </v-menu> + </template> + <template #actions> + <v-btn + :to="'/devices/' + deviceId + '/actions/' + action.id + '/edit'" + color="primary" + text + @click.stop.prevent + > + Edit + </v-btn> + </template> + </GenericActionCard> + + <template v-if="action.isDeviceSoftwareUpdateAction"> <v-card> <v-card-subtitle class="pb-0"> {{ action.updateDate | toUtcDate }} @@ -127,16 +160,16 @@ permissions and limitations under the Licence. > <v-btn icon - @click.stop.prevent="showActionItem(action.getId())" + @click.stop.prevent="showActionItem(action.id)" > - <v-icon>{{ isActionItemShown(action.getId()) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> + <v-icon>{{ isActionItemShown(action.id) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> </v-btn> </v-col> </v-row> </v-card-subtitle> <v-expand-transition> <v-card-text - v-show="isActionItemShown(action.getId())" + v-show="isActionItemShown(action.id)" class="text--primary" > <v-row dense> @@ -186,16 +219,16 @@ permissions and limitations under the Licence. > <v-btn icon - @click.stop.prevent="showActionItem(action.getId())" + @click.stop.prevent="showActionItem(action.id)" > - <v-icon>{{ isActionItemShown(action.getId()) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> + <v-icon>{{ isActionItemShown(action.id) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> </v-btn> </v-col> </v-row> </v-card-subtitle> <v-expand-transition> <v-card-text - v-show="isActionItemShown(action.getId())" + v-show="isActionItemShown(action.id)" class="text--primary" > <v-row dense> @@ -247,16 +280,16 @@ permissions and limitations under the Licence. > <v-btn icon - @click.stop.prevent="showActionItem(action.getId())" + @click.stop.prevent="showActionItem(action.id)" > - <v-icon>{{ isActionItemShown(action.getId()) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> + <v-icon>{{ isActionItemShown(action.id) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> </v-btn> </v-col> </v-row> </v-card-subtitle> <v-expand-transition> <v-card-text - v-show="isActionItemShown(action.getId())" + v-show="isActionItemShown(action.id)" class="text--primary" > <label>Parent platform</label>{{ action.parentPlatformName }} @@ -298,16 +331,16 @@ permissions and limitations under the Licence. > <v-btn icon - @click.stop.prevent="showActionItem(action.getId())" + @click.stop.prevent="showActionItem(action.id)" > - <v-icon>{{ isActionItemShown(action.getId()) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> + <v-icon>{{ isActionItemShown(action.id) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> </v-btn> </v-col> </v-row> </v-card-subtitle> <v-expand-transition> <v-card-text - v-show="isActionItemShown(action.getId())" + v-show="isActionItemShown(action.id)" class="text--primary" /> </v-expand-transition> @@ -315,79 +348,68 @@ permissions and limitations under the Licence. </template> </v-timeline-item> </v-timeline> + <v-dialog v-model="hasActionIdToDelete" max-width="290"> + <v-card> + <v-card-title class="headline"> + Delete action + </v-card-title> + <v-card-text> + Do you really want to delete the action? + </v-card-text> + <v-card-actions> + <v-btn + text + @click="hideDeleteDialog()" + > + No + </v-btn> + <v-spacer /> + <v-btn + color="error" + text + @click="deleteActionAndCloseDialog(actionIdToDelete)" + > + <v-icon left> + mdi-delete + </v-icon> + Delete + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> </template> </div> </template> -<style lang="scss"> -@import "@/assets/styles/_forms.scss"; -</style> <script lang="ts"> import { Component, Vue } from 'nuxt-property-decorator' import ProgressIndicator from '@/components/ProgressIndicator.vue' +import GenericActionCard from '@/components/GenericActionCard.vue' import { DateTime } from 'luxon' import { Contact } from '@/models/Contact' import { DeviceProperty } from '@/models/DeviceProperty' +import { IAction } from '@/models/Action' +import { GenericAction } from '@/models/GenericAction' +import { DateComparator, isDateCompareable } from '@/modelUtils/Compareables' const toUtcDate = (dt: DateTime) => { return dt.toUTC().toFormat('yyyy-MM-dd TT') } -interface IAction { - getId (): string +interface IColoredAction { getColor (): string } -class GenericDeviceAction implements IAction { - private id: string - private description: string - private beginDate: DateTime - private endDate: DateTime - private actionTypeName: string - private actionTypeUri: string - private contact: Contact - - constructor ( - id: string, - description: string, - beginDate: DateTime, - endDate: DateTime, - actionTypeName: string, - actionTypeUri: string, - contact: Contact - ) { - this.id = id - this.description = description - this.beginDate = beginDate - this.endDate = endDate - this.actionTypeName = actionTypeName - this.actionTypeUri = actionTypeUri - this.contact = contact - } - - getId (): string { - return 'generic-' + this.id - } - - getColor (): string { - return 'blue' - } - - get isGenericDeviceAction (): boolean { - return true - } -} - -class DeviceSoftwareUpdateAction implements IAction { - private id: string - private softwareTypeName: string - private softwareTypeUri: string - private updateDate: DateTime - private version: string - private repositoryUrl: string - private description: string - private contact: Contact +class DeviceSoftwareUpdateAction implements IAction, IColoredAction { + public id: string + public softwareTypeName: string + public softwareTypeUri: string + public updateDate: DateTime + public version: string + public repositoryUrl: string + public description: string + public contact: Contact constructor ( id: string, softwareTypeName: string, @@ -412,24 +434,24 @@ class DeviceSoftwareUpdateAction implements IAction { return 'software-' + this.id } - getColor (): string { - return 'yellow' + get isDeviceSoftwareUpdateAction (): boolean { + return true } - get isUpdateAction (): boolean { - return true + getColor (): string { + return 'yellow' } } -class DeviceCalibrationAction implements IAction { - private id: string - private description: string - private currentCalibrationDate: DateTime - private nextCalibrationDate: DateTime - private formula: string - private value: string - private deviceProperties: DeviceProperty[] - private contact: Contact +class DeviceCalibrationAction implements IAction, IColoredAction { + public id: string + public description: string + public currentCalibrationDate: DateTime + public nextCalibrationDate: DateTime + public formula: string + public value: string + public deviceProperties: DeviceProperty[] + public contact: Contact constructor ( id: string, description: string, @@ -454,24 +476,25 @@ class DeviceCalibrationAction implements IAction { return 'calibration-' + this.id } - getColor (): string { - return 'brown' - } - get isDeviceCalibrationAction (): boolean { return true } + + getColor (): string { + return 'brown' + } } class DeviceMountAction { - private id: string - private configurationName: string - private parentPlatformName: string - private offsetX: number - private offsetY: number - private offsetZ: number - private beginDate: DateTime - private contact: Contact + public id: string + public configurationName: string + public parentPlatformName: string + public offsetX: number + public offsetY: number + public offsetZ: number + public beginDate: DateTime + public description: string + public contact: Contact constructor ( id: string, configurationName: string, @@ -480,6 +503,7 @@ class DeviceMountAction { offsetY: number, offsetZ: number, beginDate: DateTime, + description: string, contact: Contact ) { this.id = id @@ -489,6 +513,7 @@ class DeviceMountAction { this.offsetY = offsetY this.offsetZ = offsetZ this.beginDate = beginDate + this.description = description this.contact = contact } @@ -496,29 +521,32 @@ class DeviceMountAction { return 'mount-' + this.id } - getColor (): string { - return 'green' - } - get isDeviceMountAction (): boolean { return true } + + getColor (): string { + return 'green' + } } -class DeviceUnmountAction implements IAction { - private id: string - private configurationName: string - private endDate: DateTime - private contact: Contact +class DeviceUnmountAction implements IAction, IColoredAction { + public id: string + public configurationName: string + public endDate: DateTime + public description: string + public contact: Contact constructor ( id: string, configurationName: string, endDate: DateTime, + description: string, contact: Contact ) { this.id = id this.configurationName = configurationName this.endDate = endDate + this.description = description this.contact = contact } @@ -526,18 +554,28 @@ class DeviceUnmountAction implements IAction { return 'unmount-' + this.id } + get isDeviceUnmountAction (): boolean { + return true + } + getColor (): string { return 'red' } +} - get isDeviceUnmountAction (): boolean { - return true +/** + * extend the original interface by adding the getColor() method + */ +declare module '@/models/GenericAction' { + export interface GenericAction extends IColoredAction { } } +GenericAction.prototype.getColor = (): string => 'blue' @Component({ components: { - ProgressIndicator + ProgressIndicator, + GenericActionCard }, filters: { toUtcDate @@ -550,32 +588,25 @@ export default class DeviceActionsPage extends Vue { private actions: IAction[] = [] private searchResultItemsShown: { [id: string]: boolean } = {} - /* async */ fetch () { + private actionIdToDelete: string = '' + + async fetch () { const contact1 = Contact.createFromObject({ - id: '1', + id: 'X1', givenName: 'Tech', familyName: 'Niker', email: 'tech.niker@gfz-potsdam.de', website: '' }) const contact2 = Contact.createFromObject({ - id: '2', + id: 'X2', givenName: 'Cam', familyName: 'Paign', email: 'cam.paign@gfz-potsdam.de', website: '' }) - const genericDeviceAction1 = new GenericDeviceAction( - '1', - 'Grass cut on the site', - DateTime.fromISO('2021-03-29T08:00:00.000Z'), - DateTime.fromISO('2021-03-29T08:30:00Z'), - 'Device visit', - 'actionTypes/device_visit', - contact1 - ) const deviceSoftwareUpdateAction = new DeviceSoftwareUpdateAction( - '2', + 'X2', 'Firmware', 'softwaretypes/firmware', DateTime.fromISO('2021-03-30T08:10:00Z'), @@ -589,7 +620,7 @@ export default class DeviceActionsPage extends Vue { const devProp2 = new DeviceProperty() devProp2.label = 'Wind speed' const deviceCalibrationAction1 = new DeviceCalibrationAction( - '3', + 'X3', 'Calibration of the device for usage on the campaign', DateTime.fromISO('2021-03-30T08:12:00Z'), DateTime.fromISO('2021-04-30T12:00:00Z'), @@ -599,28 +630,51 @@ export default class DeviceActionsPage extends Vue { contact2 ) const deviceMountAction1 = new DeviceMountAction( - '4', + 'X4', 'Measurement ABC', 'Station ABC', 0, 0, 2, DateTime.fromISO('2021-03-30T12:00:00Z'), + 'Mounted Measurement ABC', contact1 ) const deviceUnmountAction1 = new DeviceUnmountAction( - '5', + 'X5', 'Measurement ABC', DateTime.fromISO('2022-03-30T12:00:00Z'), + 'Unmounted Measurement ABC', contact1 ) this.actions = [ - genericDeviceAction1, deviceSoftwareUpdateAction, deviceCalibrationAction1, deviceMountAction1, deviceUnmountAction1 ] + await Promise.all([this.fetchGenericActions()]) + + // sort the actions + const comparator = new DateComparator() + this.actions.sort((i: IAction, j: IAction): number => { + if (isDateCompareable(i) && isDateCompareable(j)) { + // multiply result with -1 to get descending order + return comparator.compare(i, j) * -1 + } + if (isDateCompareable(i)) { + return 1 + } + if (isDateCompareable(j)) { + return -1 + } + return 0 + }) + } + + async fetchGenericActions (): Promise<void> { + const actions: GenericAction[] = await this.$api.devices.findRelatedGenericActions(this.deviceId) + actions.forEach((action: GenericAction) => this.actions.push(action)) } get isInProgress (): boolean { @@ -659,5 +713,79 @@ export default class DeviceActionsPage extends Vue { get deviceId (): string { return this.$route.params.deviceId } + + get actionId (): string | undefined { + return this.$route.params.actionId + } + + /** + * Returns the action object from the list of actions that is currently edited + * + * When the `/edit` route for an action is called, the `edit.vue` page is + * included via a `NuxtChild` component. To avoid of loading the action again + * in this page, we return it from this method to pass it as a property to + * the `NuxtChild` component + * + * Calls {@link DeviceActionsPage.actionId} to get the currently edited + * action from the route. + * + * @return {IAction | undefined} the found action, otherwise undefined + */ + get editedAction (): IAction | undefined { + if (!this.actionId) { + return + } + return this.actions.find(action => action.id === this.actionId) + } + + get hasActionIdToDelete (): boolean { + return !!this.actionIdToDelete + } + + showsave (isSaving: boolean) { + this.isSaving = isSaving + } + + showDeleteDialog (id: string) { + this.actionIdToDelete = id + } + + hideDeleteDialog (): void { + this.actionIdToDelete = '' + } + + deleteActionAndCloseDialog (id: string) { + this.isSaving = true + this.$api.genericDeviceActions.deleteById(id).then(() => { + this.$fetch() + this.$store.commit('snackbar/setSuccess', 'Action deleted') + }).catch((_error) => { + this.$store.commit('snackbar/setError', 'Action could not be deleted') + }).finally(() => { + this.hideDeleteDialog() + this.isSaving = false + }) + } + + getActionType (action: IAction): string { + switch (true) { + case 'isGenericAction' in action: + return 'generic-action' + case 'isDeviceSoftwareUpdateAction' in action: + return 'software-update-action' + case 'isDeviceCalibrationAction' in action: + return 'device-calibration-action' + default: + return 'unknown-action' + } + } + + getActionTypeIterationKey (action: IAction): string { + return this.getActionType(action) + '-' + action.id + } } </script> + +<style lang="scss"> +@import "@/assets/styles/_forms.scss"; +</style> diff --git a/pages/devices/_deviceId/actions/_actionId/edit.vue b/pages/devices/_deviceId/actions/_actionId/edit.vue new file mode 100644 index 0000000000000000000000000000000000000000..52eda9234c14fbb53b215299ce25df719d992deb --- /dev/null +++ b/pages/devices/_deviceId/actions/_actionId/edit.vue @@ -0,0 +1,168 @@ +<!-- +Web client of the Sensor Management System software developed within the +Helmholtz DataHub Initiative by GFZ and UFZ. + +Copyright (C) 2020, 2021 +- Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) +- Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) +- Helmholtz Centre Potsdam - GFZ German Research Centre for + Geosciences (GFZ, https://www.gfz-potsdam.de) + +Parts of this program were developed within the context of the +following publicly funded projects or measures: +- Helmholtz Earth and Environment DataHub + (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + +Licensed under the HEESIL, Version 1.0 or - as soon they will be +approved by the "Community" - subsequent versions of the HEESIL +(the "Licence"). + +You may not use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: +https://gitext.gfz-potsdam.de/software/heesil + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. +--> +<template> + <div> + <v-card-actions> + <v-spacer /> + <v-btn + v-if="isLoggedIn" + small + text + nuxt + :to="'/devices/' + deviceId + '/actions'" + > + cancel + </v-btn> + <v-btn + v-if="isLoggedIn" + color="green" + small + :disabled="isSaving" + @click="save" + > + apply + </v-btn> + </v-card-actions> + <!-- just to be consistent with the new mask, we show the selected action type as an disabled v-select here --> + <v-select + :value="valueCopy.actionTypeName" + :items="[valueCopy.actionTypeName]" + :item-text="(x) => x" + disabled + label="Action Type" + /> + <GenericActionForm + ref="genericDeviceActionForm" + v-model="valueCopy" + :attachments="attachments" + /> + <v-card-actions> + <v-spacer /> + <v-btn + v-if="isLoggedIn" + small + text + nuxt + :to="'/devices/' + deviceId + '/actions'" + > + cancel + </v-btn> + <v-btn + v-if="isLoggedIn" + color="green" + small + :disabled="isSaving" + @click="save" + > + apply + </v-btn> + </v-card-actions> + </div> +</template> + +<script lang="ts"> +import { Component, Vue, Prop, Watch } from 'nuxt-property-decorator' + +import { Attachment } from '@/models/Attachment' +import { GenericAction } from '@/models/GenericAction' + +import GenericActionForm from '@/components/GenericActionForm.vue' + +@Component({ + components: { + GenericActionForm + } +}) +export default class DeviceActionEditPage extends Vue { + private valueCopy: GenericAction = new GenericAction() + private attachments: Attachment[] = [] + private _isSaving: boolean = false + + @Prop({ + default: () => new GenericAction(), + required: true, + type: Object + }) + readonly value!: GenericAction + + created () { + if (this.value) { + this.valueCopy = GenericAction.createFromObject(this.value) + } + } + + async fetch (): Promise<any> { + try { + this.attachments = await this.$api.devices.findRelatedDeviceAttachments(this.deviceId) + } catch (_) { + this.$store.commit('snackbar/setError', 'Failed to fetch attachments') + } + } + + get deviceId (): string { + return this.$route.params.deviceId + } + + get isLoggedIn (): boolean { + return this.$store.getters['oidc/isAuthenticated'] + } + + get isSaving (): boolean { + return this.$data._isSaving + } + + set isSaving (value: boolean) { + this.$data._isSaving = value + this.$emit('showsave', value) + } + + save (): void { + if (!(this.$refs.genericDeviceActionForm as Vue & { isValid: () => boolean }).isValid()) { + this.$store.commit('snackbar/setError', 'Please correct the errors') + return + } + this.isSaving = true + this.$api.genericDeviceActions.update(this.deviceId, this.valueCopy).then((action: GenericAction) => { + this.$router.push('/devices/' + this.deviceId + '/actions', () => this.$emit('input', action)) + }).catch(() => { + this.$store.commit('snackbar/setError', 'Failed to save the action') + }).finally(() => { + this.isSaving = false + }) + } + + @Watch('value', { immediate: true, deep: true }) + // @ts-ignore + onValueChanged (val: GenericAction) { + this.valueCopy = GenericAction.createFromObject(val) + } +} +</script> diff --git a/pages/devices/_deviceId/actions/new.vue b/pages/devices/_deviceId/actions/new.vue index 65c0f9fcd1338a772360c4e17c60ad5e2859a64f..dc3320194fb0d05b31aaf341c5b66539dfb91b29 100644 --- a/pages/devices/_deviceId/actions/new.vue +++ b/pages/devices/_deviceId/actions/new.vue @@ -63,10 +63,11 @@ permissions and limitations under the Licence. Add </v-btn> <v-btn - v-else-if="otherChosen" + v-else-if="genericActionChosen" color="green" small - @click="addGenericDeviceAction" + :disabled="isSaving" + @click="addGenericAction" > Add </v-btn> @@ -74,7 +75,7 @@ permissions and limitations under the Licence. <v-card-text> <v-select v-model="chosenKindOfAction" - :items="optionsForActionType" + :items="actionTypeItems" :item-text="(x) => x.name" :item-value="(x) => x" clearable @@ -172,36 +173,17 @@ permissions and limitations under the Licence. </v-row> </v-card-text> <v-card-text - v-if="otherChosen" + v-if="genericActionChosen" > - <v-form - ref="datesForm" - v-model="datesAreValid" - @submit.prevent - > - <v-row> - <v-col cols="12" md="6"> - <DatePicker - :value="startDate" - label="Start date" - :rules="[rules.startDate, rules.startDateNotNull]" - @input="setStartDateAndValidate" - /> - </v-col> - <v-col cols="12" md="6"> - <DatePicker - :value="endDate" - label="End date" - :rules="[rules.endDate]" - @input="setEndDateAndValidate" - /> - </v-col> - </v-row> - </v-form> + <GenericActionForm + ref="genericDeviceActionForm" + v-model="genericDeviceAction" + :attachments="attachments" + /> </v-card-text> <!-- action type independent --> <v-card-text - v-if="chosenKindOfAction" + v-if="chosenKindOfAction && !genericActionChosen" > <v-row> <v-col cols="12" md="12"> @@ -278,10 +260,11 @@ permissions and limitations under the Licence. Add </v-btn> <v-btn - v-else-if="otherChosen" + v-else-if="genericActionChosen" color="green" small - @click="addGenericDeviceAction" + :disabled="isSaving" + @click="addGenericAction" > Add </v-btn> @@ -297,27 +280,29 @@ import { DateTime } from 'luxon' import { Contact } from '@/models/Contact' import { Attachment } from '@/models/Attachment' import { DeviceProperty } from '@/models/DeviceProperty' +import { GenericAction } from '@/models/GenericAction' +import { IActionType, ActionType } from '@/models/ActionType' + +import { ACTION_TYPE_API_FILTER_DEVICE } from '@/services/cv/ActionTypeApi' import { dateToString, stringToDate } from '@/utils/dateHelper' +import GenericActionForm from '@/components/GenericActionForm.vue' import DatePicker from '@/components/DatePicker.vue' -type KindOfActionType = 'device_calibration' | 'software_update' | 'generic_device_action' +const KIND_OF_ACTION_TYPE_DEVICE_CALIBRATION = 'device_calibration' +const KIND_OF_ACTION_TYPE_SOFTWARE_UPDATE = 'software_update' +const KIND_OF_ACTION_TYPE_GENERIC_DEVICE_ACTION = 'generic_device_action' +const KIND_OF_ACTION_TYPE_UNKNOWN = 'unknown' +type KindOfActionType = typeof KIND_OF_ACTION_TYPE_DEVICE_CALIBRATION | typeof KIND_OF_ACTION_TYPE_SOFTWARE_UPDATE | typeof KIND_OF_ACTION_TYPE_GENERIC_DEVICE_ACTION | typeof KIND_OF_ACTION_TYPE_UNKNOWN -interface IGenericActionType { - id: string - uri: string - name: string -} - -interface IOptionsForActionType { - id: string +type IOptionsForActionType = Pick<IActionType, 'id' | 'name' | 'uri'> & { kind: KindOfActionType - name: string } @Component({ components: { + GenericActionForm, DatePicker } }) @@ -336,13 +321,23 @@ export default class ActionAddPage extends Vue { private contactIsValid = true private softwareTypeIsValid = true - private optionsForActionType: IOptionsForActionType[] = [ - { id: 'device-calibration', kind: 'device_calibration', name: 'Device calibration' }, - { id: 'software-update', kind: 'software_update', name: 'Software update' }, - { id: 'generic-action-1', kind: 'generic_device_action', /* uri: 'actionTypes/device_visit', */ name: 'Device visit' }, - { id: 'generic-action-2', kind: 'generic_device_action', /* uri: 'actionTypes/device_maintainance', */ name: 'Device maintainance' } + private specialActionTypes: IOptionsForActionType[] = [ + { + id: 'device_calibration', + name: 'Device Calibration', + uri: '', + kind: KIND_OF_ACTION_TYPE_DEVICE_CALIBRATION + }, + { + id: 'software_update', + name: 'Software Update', + uri: '', + kind: KIND_OF_ACTION_TYPE_SOFTWARE_UPDATE + } ] + private genericActionTypes: ActionType[] = [] + private contacts: Contact[] = [] private selectedContact: Contact | null = null private readonly labelForSelectMeButton = 'Add current user' @@ -363,6 +358,20 @@ export default class ActionAddPage extends Vue { private startDate: DateTime | null = null private endDate: DateTime | null = null + private genericDeviceAction: GenericAction = new GenericAction() + + private _isSaving: boolean = false + + async fetch () { + await Promise.all([ + this.fetchGenericActionTypes() + ]) + } + + async fetchGenericActionTypes (): Promise<any> { + this.genericActionTypes = await this.$api.actionTypes.newSearchBuilder().onlyType(ACTION_TYPE_API_FILTER_DEVICE).build().findMatchingAsList() + } + mounted () { this.$api.contacts.findAll().then((foundContacts) => { this.contacts = foundContacts @@ -403,6 +412,11 @@ export default class ActionAddPage extends Vue { if (this.$data._chosenKindOfAction?.kind !== newValue?.kind) { this.resetAllActionSpecificInputs() } + if (this.genericActionChosen) { + this.genericDeviceAction = new GenericAction() + this.genericDeviceAction.actionTypeName = newValue?.name || '' + this.genericDeviceAction.actionTypeUrl = newValue?.uri || '' + } } } @@ -416,15 +430,15 @@ export default class ActionAddPage extends Vue { } get deviceCalibrationChosen () { - return this.$data._chosenKindOfAction?.kind === 'device_calibration' + return this.$data._chosenKindOfAction?.kind === KIND_OF_ACTION_TYPE_DEVICE_CALIBRATION } get softwareUpdateChosen () { - return this.$data._chosenKindOfAction?.kind === 'software_update' + return this.$data._chosenKindOfAction?.kind === KIND_OF_ACTION_TYPE_SOFTWARE_UPDATE } - get otherChosen () { - return this.$data._chosenKindOfAction?.kind === 'generic_device_action' + get genericActionChosen () { + return this.$data._chosenKindOfAction?.kind === KIND_OF_ACTION_TYPE_GENERIC_DEVICE_ACTION } getStartDate (): string { @@ -509,6 +523,15 @@ export default class ActionAddPage extends Vue { return this.$route.params.deviceId } + get isSaving (): boolean { + return this.$data._isSaving + } + + set isSaving (value: boolean) { + this.$data._isSaving = value + this.$emit('showsave', value) + } + addDeviceCalibrationAction () { if (!(this.$refs.datesForm as Vue & { validate: () => boolean }).validate()) { return @@ -533,14 +556,40 @@ export default class ActionAddPage extends Vue { this.$store.commit('snackbar/setError', 'Not implemented yet') } - addGenericDeviceAction () { - if (!(this.$refs.datesForm as Vue & { validate: () => boolean }).validate()) { + addGenericAction () { + if (!this.isLoggedIn) { return } - if (!(this.$refs.contactForm as Vue & { validate: () => boolean}).validate()) { + if (!this.genericDeviceAction) { return } - this.$store.commit('snackbar/setError', 'Not implemented yet') + if (!(this.$refs.genericDeviceActionForm as Vue & { isValid: () => boolean }).isValid()) { + this.isSaving = false + this.$store.commit('snackbar/setError', 'Please correct the errors') + return + } + this.isSaving = true + this.$api.genericDeviceActions.add(this.deviceId, this.genericDeviceAction).then((action: GenericAction) => { + this.$router.push('/devices/' + this.deviceId + '/actions', () => this.$emit('input', action)) + }).catch(() => { + this.$store.commit('snackbar/setError', 'Failed to save the action') + }).finally(() => { + this.isSaving = false + }) + } + + get actionTypeItems (): IOptionsForActionType[] { + return [ + ...this.specialActionTypes, + ...this.genericActionTypes.map((i) => { + return { + id: i.id, + name: i.name, + uri: i.uri, + kind: KIND_OF_ACTION_TYPE_GENERIC_DEVICE_ACTION + } + }) + ] as IOptionsForActionType[] } } diff --git a/serializers/jsonapi/ActionTypeSerializer.ts b/serializers/jsonapi/ActionTypeSerializer.ts new file mode 100644 index 0000000000000000000000000000000000000000..d57e1d11eee7e05501071a835574f15f0ba8d2b4 --- /dev/null +++ b/serializers/jsonapi/ActionTypeSerializer.ts @@ -0,0 +1,52 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { ActionType } from '@/models/ActionType' + +import { + IJsonApiEntityListEnvelope, + IJsonApiEntity +} from '@/serializers/jsonapi/JsonApiTypes' + +export class ActionTypeSerializer { + convertJsonApiObjectListToModelList (jsonApiObjectList: IJsonApiEntityListEnvelope): ActionType[] { + return jsonApiObjectList.data.map(this.convertJsonApiDataToModel.bind(this)) + } + + convertJsonApiDataToModel (jsonApiData: IJsonApiEntity): ActionType { + const id = jsonApiData.id.toString() + const name = jsonApiData.attributes.term + const url = jsonApiData.links?.self || '' + const definition = jsonApiData.attributes.definition + + return ActionType.createWithData(id, name, url, definition) + } +} diff --git a/serializers/jsonapi/ContactSerializer.ts b/serializers/jsonapi/ContactSerializer.ts index afe5340ca72f804d4afb7223395fef0bc42e1a64..b697a3df62c09d3cafb67b28e2bcedddb9237605 100644 --- a/serializers/jsonapi/ContactSerializer.ts +++ b/serializers/jsonapi/ContactSerializer.ts @@ -185,7 +185,7 @@ export class ContactSerializer { } } - convertJsonApiRelationshipsSingleModel (relationships: IJsonApiTypedEntityWithoutDetailsDataDict, included: IJsonApiEntityWithOptionalAttributes[]): IContactAndMissing { + convertJsonApiRelationshipsSingleModel (relationships: IJsonApiRelationships, included: IJsonApiEntityWithOptionalAttributes[]): IContactAndMissing { let relationContactId: string = '' if (relationships.contact) { const contactObject = relationships.contact as IJsonApiEntityWithoutDetailsDataDict diff --git a/serializers/jsonapi/GenericActionAttachmentSerializer.ts b/serializers/jsonapi/GenericActionAttachmentSerializer.ts new file mode 100644 index 0000000000000000000000000000000000000000..2500a9899523dbc447cf23c6a7bf7591aca39cd6 --- /dev/null +++ b/serializers/jsonapi/GenericActionAttachmentSerializer.ts @@ -0,0 +1,165 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { Attachment } from '@/models/Attachment' + +import { + IJsonApiEntityWithOptionalId, + IJsonApiEntityWithOptionalAttributes, + IJsonApiRelationships, + IJsonApiEntityWithoutDetails, + IJsonApiEntityWithoutDetailsDataDictList +} from '@/serializers/jsonapi/JsonApiTypes' + +import { DeviceAttachmentSerializer } from '@/serializers/jsonapi/DeviceAttachmentSerializer' + +export interface IGenericActionAttachmentSerializer { + targetType: string + convertModelToJsonApiData (attachment: Attachment, actionId: string): IJsonApiEntityWithOptionalId + convertJsonApiRelationshipsModelList (relationships: IJsonApiRelationships, included: IJsonApiEntityWithOptionalAttributes[]): Attachment[] + getActionTypeName (): string + getActionAttachmentTypeName (): string + getActionAttachmentTypeNamePlural (): string + getAttachmentTypeName (): string +} + +export abstract class AbstractGenericActionAttachmentSerializer implements IGenericActionAttachmentSerializer { + private attachmentSerializer: DeviceAttachmentSerializer = new DeviceAttachmentSerializer() + + abstract get targetType (): string + + convertModelToJsonApiData (attachment: Attachment, actionId: string): IJsonApiEntityWithOptionalId { + /** + * 2021-05-07 mha: + * We build the relation to the action by hand instead of using the + * GenericActionSerializer, to avoid circular references. We also + * build the relation to the attachment by hand instead of using the + * DeviceAttachmentSerializer, which uses 'device_attachment' as its + * property whereas we need 'attachment' as the property and + * 'device_attachment' as the type. + */ + const entityType = this.getActionAttachmentTypeName() + const actionType = this.getActionTypeName() + const attachmentType = this.getAttachmentTypeName() + const data: IJsonApiEntityWithOptionalId = { + type: entityType, + attributes: {}, + relationships: { + action: { + data: { + type: actionType, + id: actionId + } + }, + attachment: { + data: { + type: attachmentType, + id: attachment.id || '' + } + } + } + } + return data + } + + convertJsonApiRelationshipsModelList (relationships: IJsonApiRelationships, included: IJsonApiEntityWithOptionalAttributes[]): Attachment[] { + const actionAttachmentIds = [] + const entityType = this.getActionAttachmentTypeName() + const typePlural = this.getActionAttachmentTypeNamePlural() + if (relationships[typePlural]) { + const attachmentObject = relationships[typePlural] as IJsonApiEntityWithoutDetailsDataDictList + if (attachmentObject.data && (attachmentObject.data as IJsonApiEntityWithoutDetails[]).length > 0) { + for (const relationShipAttachmentData of attachmentObject.data as IJsonApiEntityWithoutDetails[]) { + const actionAttachmentId = relationShipAttachmentData.id + actionAttachmentIds.push(actionAttachmentId) + } + } + } + + const attachmentIds = [] + if (included && included.length > 0) { + for (const includedEntry of included) { + if (includedEntry.type === entityType) { + const actionAttachmentId = includedEntry.id + if (actionAttachmentIds.includes(actionAttachmentId)) { + if ((includedEntry.relationships?.attachment?.data as IJsonApiEntityWithoutDetails | undefined)?.id) { + attachmentIds.push((includedEntry.relationships?.attachment?.data as IJsonApiEntityWithoutDetails).id) + } + } + } + } + } + + const attachmentType = this.getAttachmentTypeName() + const attachments: Attachment[] = [] + if (included && included.length > 0) { + for (const includedEntry of included) { + if (includedEntry.type === attachmentType) { + const attachmentId = includedEntry.id + if (attachmentIds.includes(attachmentId)) { + const attachment = this.attachmentSerializer.convertJsonApiDataToModel(includedEntry) + attachments.push(attachment) + } + } + } + } + + return attachments + } + + getActionTypeName (): string { + return 'generic_' + this.targetType + '_action' + } + + getActionAttachmentTypeName (): string { + return 'generic_' + this.targetType + '_action_attachment' + } + + getActionAttachmentTypeNamePlural (): string { + return this.getActionAttachmentTypeName() + 's' + } + + getAttachmentTypeName (): string { + return this.targetType + '_attachment' + } +} + +export class GenericDeviceActionAttachmentSerializer extends AbstractGenericActionAttachmentSerializer { + get targetType (): string { + return 'device' + } +} + +export class GenericPlatformActionAttachmentSerializer extends AbstractGenericActionAttachmentSerializer { + get targetType (): string { + return 'platform' + } +} diff --git a/serializers/jsonapi/GenericActionSerializer.ts b/serializers/jsonapi/GenericActionSerializer.ts new file mode 100644 index 0000000000000000000000000000000000000000..2360bb662a76eb40c8884a06b2b6bbdd63b2423c --- /dev/null +++ b/serializers/jsonapi/GenericActionSerializer.ts @@ -0,0 +1,295 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { DateTime } from 'luxon' +import { GenericAction, IGenericAction } from '@/models/GenericAction' +import { Attachment } from '@/models/Attachment' +import { + IJsonApiEntityEnvelope, + IJsonApiEntityListEnvelope, + IJsonApiEntityWithOptionalId, + IJsonApiEntityWithOptionalAttributes, + IJsonApiEntityWithoutDetails, + IJsonApiEntityWithoutDetailsDataDictList, + IJsonApiRelationships +} from '@/serializers/jsonapi/JsonApiTypes' + +import { + ContactSerializer, + IContactAndMissing +} from '@/serializers/jsonapi/ContactSerializer' + +import { + IGenericActionAttachmentSerializer, + GenericDeviceActionAttachmentSerializer, + GenericPlatformActionAttachmentSerializer +} from '@/serializers/jsonapi/GenericActionAttachmentSerializer' + +export interface IMissingGenericActionData { + ids: string[] +} + +export interface IGenericActionsAndMissing { + genericDeviceActions: GenericAction[] + missing: IMissingGenericActionData +} + +export interface IGenericActionAttachmentRelation { + genericActionAttachmentId: string + attachmentId: string +} + +export interface IGenericActionSerializer { + targetType: string + attachmentSerializer: IGenericActionAttachmentSerializer + convertJsonApiObjectToModel (jsonApiObject: IJsonApiEntityEnvelope): GenericAction + convertJsonApiDataToModel (jsonApiData: IJsonApiEntityWithOptionalAttributes, included: IJsonApiEntityWithOptionalAttributes[]): GenericAction + convertJsonApiObjectListToModelList (jsonApiObjectList: IJsonApiEntityListEnvelope): GenericAction[] + convertModelToJsonApiData (action: GenericAction, deviceOrPlatformId: string): IJsonApiEntityWithOptionalId + convertModelToJsonApiRelationshipObject (action: IGenericAction): IJsonApiRelationships + convertModelToTupleWithIdAndType (action: IGenericAction): IJsonApiEntityWithoutDetails + convertJsonApiRelationshipsModelList (relationships: IJsonApiRelationships, included: IJsonApiEntityWithOptionalAttributes[]): IGenericActionsAndMissing + convertJsonApiIncludedGenericActionAttachmentsToIdList (included: IJsonApiEntityWithOptionalAttributes[]): IGenericActionAttachmentRelation[] + getActionTypeName (): string + getActionTypeNamePlural (): string + getActionAttachmentTypeName (): string +} + +export abstract class AbstractGenericActionSerializer implements IGenericActionSerializer { + private contactSerializer: ContactSerializer = new ContactSerializer() + + abstract get targetType (): string + abstract get attachmentSerializer (): IGenericActionAttachmentSerializer + + convertJsonApiObjectToModel (jsonApiObject: IJsonApiEntityEnvelope): GenericAction { + const data = jsonApiObject.data + const included = jsonApiObject.included || [] + return this.convertJsonApiDataToModel(data, included) + } + + convertJsonApiDataToModel (jsonApiData: IJsonApiEntityWithOptionalAttributes, included: IJsonApiEntityWithOptionalAttributes[]): GenericAction { + const attributes = jsonApiData.attributes + const newEntry = GenericAction.createEmpty() + + newEntry.id = jsonApiData.id.toString() + if (attributes) { + newEntry.description = attributes.description || '' + newEntry.actionTypeName = attributes.action_type_name || '' + newEntry.actionTypeUrl = attributes.action_type_uri || '' + newEntry.beginDate = attributes.begin_date ? DateTime.fromISO(attributes.begin_date, { zone: 'UTC' }) : null + newEntry.endDate = attributes.end_date ? DateTime.fromISO(attributes.end_date, { zone: 'UTC' }) : null + } + + const relationships = jsonApiData.relationships || {} + + const contactWithMissing: IContactAndMissing = this.contactSerializer.convertJsonApiRelationshipsSingleModel(relationships, included) + if (contactWithMissing.contact) { + newEntry.contact = contactWithMissing.contact + } + + const attachments: Attachment[] = this.attachmentSerializer.convertJsonApiRelationshipsModelList(relationships, included) + if (attachments.length) { + newEntry.attachments = attachments + } + + return newEntry + } + + convertJsonApiObjectListToModelList (jsonApiObjectList: IJsonApiEntityListEnvelope): GenericAction[] { + const included = jsonApiObjectList.included || [] + return jsonApiObjectList.data.map((model) => { + return this.convertJsonApiDataToModel(model, included) + }) + } + + convertModelToJsonApiData (action: GenericAction, deviceOrPlatformId: string): IJsonApiEntityWithOptionalId { + const data: IJsonApiEntityWithOptionalId = { + type: this.getActionTypeName(), + attributes: { + description: action.description, + action_type_name: action.actionTypeName, + action_type_uri: action.actionTypeUrl, + begin_date: action.beginDate != null ? action.beginDate.setZone('UTC').toISO() : null, + end_date: action.endDate != null ? action.endDate.setZone('UTC').toISO() : null + }, + relationships: { + [this.targetType]: { + data: { + type: this.targetType, + id: deviceOrPlatformId + } + } + } + } + if (action.id) { + data.id = action.id + } + if (action.contact && action.contact.id) { + const contactRelationship = this.contactSerializer.convertModelToJsonApiRelationshipObject(action.contact) + data.relationships = { + ...data.relationships, + ...contactRelationship + } + } + // Note: Attachments are not included and must be send to the backend with + // a relation to the action after this action was saved + return data + } + + convertModelToJsonApiRelationshipObject (action: IGenericAction): IJsonApiRelationships { + return { + [this.getActionTypeName()]: { + data: this.convertModelToTupleWithIdAndType(action) + } + } + } + + convertModelToTupleWithIdAndType (action: IGenericAction): IJsonApiEntityWithoutDetails { + return { + id: action.id || '', + type: this.getActionTypeName() + } + } + + convertJsonApiRelationshipsModelList (relationships: IJsonApiRelationships, included: IJsonApiEntityWithOptionalAttributes[]): IGenericActionsAndMissing { + const actionIds = [] + const type = this.getActionTypeName() + const typePlural = this.getActionTypeNamePlural() + if (relationships[typePlural]) { + const actionObject = relationships[typePlural] as IJsonApiEntityWithoutDetailsDataDictList + if (actionObject.data && (actionObject.data as IJsonApiEntityWithoutDetails[]).length > 0) { + for (const relationShipActionData of actionObject.data) { + const actionId = relationShipActionData.id + actionIds.push(actionId) + } + } + } + + const possibleActions: { [key: string]: GenericAction } = {} + if (included && included.length > 0) { + for (const includedEntry of included) { + if (includedEntry.type === type) { + const actionId = includedEntry.id + if (actionIds.includes(actionId)) { + const action = this.convertJsonApiDataToModel(includedEntry, []) + possibleActions[actionId] = action + } + } + } + } + + const actions = [] + const missingDataForActionIds = [] + + for (const actionId of actionIds) { + if (possibleActions[actionId]) { + actions.push(possibleActions[actionId]) + } else { + missingDataForActionIds.push(actionId) + } + } + + return { + genericDeviceActions: actions, + missing: { + ids: missingDataForActionIds + } + } + } + + convertJsonApiIncludedGenericActionAttachmentsToIdList (included: IJsonApiEntityWithOptionalAttributes[]): IGenericActionAttachmentRelation[] { + const linkedAttachments: IGenericActionAttachmentRelation[] = [] + const type = this.getActionAttachmentTypeName() + included.forEach((i) => { + if (!i.id) { + return + } + if (i.type !== type) { + return + } + if (!i.relationships?.attachment || !i.relationships?.attachment.data || !(i.relationships?.attachment.data as IJsonApiEntityWithoutDetails).id) { + return + } + const attachmentId: string = (i.relationships.attachment.data as IJsonApiEntityWithoutDetails).id + linkedAttachments.push({ + genericActionAttachmentId: i.id, + attachmentId + }) + }) + return linkedAttachments + } + + getActionTypeName (): string { + return 'generic_' + this.targetType + '_action' + } + + getActionTypeNamePlural (): string { + return this.getActionTypeName() + 's' + } + + getActionAttachmentTypeName (): string { + return 'generic_' + this.targetType + '_action_attachment' + } +} + +export class GenericDeviceActionSerializer extends AbstractGenericActionSerializer { + private _attachmentSerializer: IGenericActionAttachmentSerializer + + get targetType (): string { + return 'device' + } + + get attachmentSerializer (): IGenericActionAttachmentSerializer { + return this._attachmentSerializer + } + + constructor () { + super() + this._attachmentSerializer = new GenericDeviceActionAttachmentSerializer() + } +} + +export class GenericPlatformActionSerializer extends AbstractGenericActionSerializer { + private _attachmentSerializer: IGenericActionAttachmentSerializer + + get targetType (): string { + return 'platform' + } + + get attachmentSerializer (): IGenericActionAttachmentSerializer { + return this._attachmentSerializer + } + + constructor () { + super() + this._attachmentSerializer = new GenericPlatformActionAttachmentSerializer() + } +} diff --git a/services/Api.ts b/services/Api.ts index 2ba69c48a03819d1c7ca08f1a40b617f498f6860..551e38f1a6e40d1dd7b065e61f0e6ce4af9ddacd 100644 --- a/services/Api.ts +++ b/services/Api.ts @@ -40,6 +40,8 @@ import { ConfigurationStatusApi } from '@/services/sms/ConfigurationStatusApi' import { CustomfieldsApi } from '@/services/sms/CustomfieldsApi' import { DeviceAttachmentApi } from '@/services/sms/DeviceAttachmentApi' import { PlatformAttachmentApi } from '@/services/sms/PlatformAttachmentApi' +import { GenericDeviceActionApi } from '@/services/sms/GenericDeviceActionApi' +import { GenericDeviceActionAttachmentApi } from '@/services/sms/GenericDeviceActionAttachmentApi' import { CompartmentApi } from '@/services/cv/CompartmentApi' import { DeviceTypeApi } from '@/services/cv/DeviceTypeApi' @@ -50,6 +52,7 @@ import { SamplingMediaApi } from '@/services/cv/SamplingMediaApi' import { StatusApi } from '@/services/cv/StatusApi' import { UnitApi } from '@/services/cv/UnitApi' import { MeasuredQuantityUnitApi } from '@/services/cv/MeasuredQuantityUnitApi' +import { ActionTypeApi } from '@/services/cv/ActionTypeApi' import { ProjectApi } from '@/services/project/ProjectApi' @@ -66,6 +69,8 @@ export class Api { private readonly _deviceAttachmentApi: DeviceAttachmentApi private readonly _platformAttachmentApi: PlatformAttachmentApi private readonly _devicePropertyApi: DevicePropertyApi + private readonly _genericDeviceActionApi: GenericDeviceActionApi + private readonly _genericDeviceActionAttachmentApi: GenericDeviceActionAttachmentApi private readonly _manufacturerApi: ManufacturerApi private readonly _platformTypeApi: PlatformTypeApi @@ -76,6 +81,7 @@ export class Api { private readonly _propertyApi: PropertyApi private readonly _unitApi: UnitApi private readonly _measuredQuantityUnitApi: MeasuredQuantityUnitApi + private readonly _actionTypeApi: ActionTypeApi private readonly _projectApi: ProjectApi @@ -123,6 +129,15 @@ export class Api { this.createAxios(smsBaseUrl, '/device-properties', smsConfig, getIdToken) ) + this._genericDeviceActionAttachmentApi = new GenericDeviceActionAttachmentApi( + this.createAxios(smsBaseUrl, '/generic-device-action-attachments', smsConfig, getIdToken) + ) + + this._genericDeviceActionApi = new GenericDeviceActionApi( + this.createAxios(smsBaseUrl, '/generic-device-actions', smsConfig, getIdToken), + this._genericDeviceActionAttachmentApi + ) + // and here we can set settings for all the cv api calls const cvConfig: AxiosRequestConfig = { headers: { @@ -159,6 +174,9 @@ export class Api { this._measuredQuantityUnitApi = new MeasuredQuantityUnitApi( this.createAxios(cvBaseUrl, '/measuredquantityunits/', cvConfig) ) + this._actionTypeApi = new ActionTypeApi( + this.createAxios(cvBaseUrl, '/actiontypes/', cvConfig) + ) this._projectApi = new ProjectApi() } @@ -219,6 +237,14 @@ export class Api { return this._devicePropertyApi } + get genericDeviceActions (): GenericDeviceActionApi { + return this._genericDeviceActionApi + } + + get genericDeviceActionAttachments (): GenericDeviceActionAttachmentApi { + return this._genericDeviceActionAttachmentApi + } + get contacts (): ContactApi { return this._contactApi } @@ -259,6 +285,10 @@ export class Api { return this._measuredQuantityUnitApi } + get actionTypes (): ActionTypeApi { + return this._actionTypeApi + } + get projects (): ProjectApi { return this._projectApi } diff --git a/services/cv/ActionTypeApi.ts b/services/cv/ActionTypeApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0b1f8962ddef851d0e497ab8e622c01ddda2417 --- /dev/null +++ b/services/cv/ActionTypeApi.ts @@ -0,0 +1,155 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { AxiosInstance } from 'axios' + +import { ActionType } from '@/models/ActionType' +import { ActionTypeSerializer } from '@/serializers/jsonapi/ActionTypeSerializer' +import { CVApi } from '@/services/cv/CVApi' + +import { IPaginationLoader } from '@/utils/PaginatedLoader' + +export class ActionTypeApi extends CVApi<ActionType> { + private serializer: ActionTypeSerializer + + constructor (axiosInstance: AxiosInstance) { + super(axiosInstance) + this.serializer = new ActionTypeSerializer() + } + + newSearchBuilder (): ActionTypeSearchBuilder { + return new ActionTypeSearchBuilder(this.axiosApi, this.serializer) + } + + findAll (): Promise<ActionType[]> { + return this.newSearchBuilder().build().findMatchingAsList() + } + + findAllPaginated (pageSize: number = 100): Promise<ActionType[]> { + return this.newSearchBuilder().build().findMatchingAsPaginationLoader(pageSize).then(loader => this.loadPaginated(loader)) + } +} + +export const ACTION_TYPE_API_FILTER_DEVICE = 'Device' +export const ACTION_TYPE_API_FILTER_PLATFORM = 'Platform' +export const ACTION_TYPE_API_FILTER_CONFIGURATION = 'Configuration' +export type ActionTypeApiFilterType = typeof ACTION_TYPE_API_FILTER_DEVICE | typeof ACTION_TYPE_API_FILTER_PLATFORM | typeof ACTION_TYPE_API_FILTER_CONFIGURATION + +export class ActionTypeSearchBuilder { + private axiosApi: AxiosInstance + private serializer: ActionTypeSerializer + private actionTypeFilter: ActionTypeApiFilterType | undefined + + constructor (axiosApi: AxiosInstance, serializer: ActionTypeSerializer) { + this.axiosApi = axiosApi + this.serializer = serializer + } + + onlyType (actionType: ActionTypeApiFilterType): ActionTypeSearchBuilder { + this.actionTypeFilter = actionType + return this + } + + build (): ActionTypeSearcher { + return new ActionTypeSearcher(this.axiosApi, this.serializer, this.actionTypeFilter) + } +} + +export class ActionTypeSearcher { + private axiosApi: AxiosInstance + private serializer: ActionTypeSerializer + private actionTypeFilter: ActionTypeApiFilterType | undefined + + constructor (axiosApi: AxiosInstance, serializer: ActionTypeSerializer, actionType?: ActionTypeApiFilterType) { + this.axiosApi = axiosApi + this.serializer = serializer + if (actionType) { + this.actionTypeFilter = actionType + } + } + + private findAllOnPage (page: number, pageSize: number): Promise<IPaginationLoader<ActionType>> { + const params: { [idx: string]: any } = { + 'page[size]': pageSize, + 'page[number]': page, + 'filter[status.iexact]': 'ACCEPTED', + sort: 'term' + } + if (this.actionTypeFilter) { + params['filter[action_category__term]'] = this.actionTypeFilter + } + return this.axiosApi.get( + '', + { + params + } + ).then((rawResponse) => { + const response = rawResponse.data + const elements: ActionType[] = this.serializer.convertJsonApiObjectListToModelList(response) + const totalCount = response.meta.count + + let funToLoadNext = null + if (response.meta.pagination.page < response.meta.pagination.pages) { + funToLoadNext = () => this.findAllOnPage(page + 1, pageSize) + } + + return { + elements, + totalCount, + funToLoadNext + } + }) + } + + findMatchingAsList (): Promise<ActionType[]> { + const params: { [idx: string]: any } = { + 'page[size]': 10000, + 'filter[status.iexact]': 'ACCEPTED', + sort: 'term' + } + if (this.actionTypeFilter) { + params['filter[action_category__term]'] = this.actionTypeFilter + } + return this.axiosApi.get( + '', + { + params + } + ).then((rawResponse) => { + const response = rawResponse.data + return this.serializer.convertJsonApiObjectListToModelList(response) + }) + } + + findMatchingAsPaginationLoader (pageSize: number): Promise<IPaginationLoader<ActionType>> { + return this.findAllOnPage(1, pageSize) + } +} diff --git a/services/sms/DeviceApi.ts b/services/sms/DeviceApi.ts index 914c16848cb76efc4597bd09c414364607894bd6..f8f5e6deca33fff04e58022cd47fae6b6cae13ab 100644 --- a/services/sms/DeviceApi.ts +++ b/services/sms/DeviceApi.ts @@ -39,11 +39,13 @@ import { DeviceProperty } from '@/models/DeviceProperty' import { DeviceType } from '@/models/DeviceType' import { Manufacturer } from '@/models/Manufacturer' import { Status } from '@/models/Status' +import { GenericAction } from '@/models/GenericAction' import { ContactSerializer } from '@/serializers/jsonapi/ContactSerializer' import { CustomTextFieldSerializer } from '@/serializers/jsonapi/CustomTextFieldSerializer' import { DeviceAttachmentSerializer } from '@/serializers/jsonapi/DeviceAttachmentSerializer' import { DevicePropertySerializer } from '@/serializers/jsonapi/DevicePropertySerializer' +import { GenericDeviceActionSerializer } from '@/serializers/jsonapi/GenericActionSerializer' import { IFlaskJSONAPIFilter } from '@/utils/JSONApiInterfaces' @@ -190,6 +192,20 @@ export class DeviceApi { return new DevicePropertySerializer().convertJsonApiObjectListToModelList(rawServerResponse.data) }) } + + findRelatedGenericActions (deviceId: string): Promise<GenericAction[]> { + const url = deviceId + '/generic-device-actions' + const params = { + 'page[size]': 10000, + include: [ + 'contact', + 'generic_device_action_attachments.attachment' + ].join(',') + } + return this.axiosApi.get(url, { params }).then((rawServerResponse) => { + return new GenericDeviceActionSerializer().convertJsonApiObjectListToModelList(rawServerResponse.data) + }) + } } export class DeviceSearchBuilder { diff --git a/services/sms/GenericDeviceActionApi.ts b/services/sms/GenericDeviceActionApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..4faf5eb067408d8b836f2b9d53b93b26de93619e --- /dev/null +++ b/services/sms/GenericDeviceActionApi.ts @@ -0,0 +1,146 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { AxiosInstance } from 'axios' + +import { Attachment } from '@/models/Attachment' +import { GenericAction } from '@/models/GenericAction' +import { GenericDeviceActionAttachmentApi } from '@/services/sms/GenericDeviceActionAttachmentApi' +import { IGenericActionSerializer, GenericDeviceActionSerializer } from '@/serializers/jsonapi/GenericActionSerializer' + +export class GenericDeviceActionApi { + private axiosApi: AxiosInstance + private serializer: IGenericActionSerializer + private attachmentApi: GenericDeviceActionAttachmentApi + + constructor (axiosInstance: AxiosInstance, attachmentApi: GenericDeviceActionAttachmentApi) { + this.axiosApi = axiosInstance + this.serializer = new GenericDeviceActionSerializer() + this.attachmentApi = attachmentApi + } + + async findById (id: string): Promise<GenericAction> { + const response = await this.axiosApi.get(id, { + params: { + include: [ + 'contact', + 'generic_device_action_attachments.attachment' + ].join(',') + } + }) + const data = response.data + return this.serializer.convertJsonApiObjectToModel(data) + } + + deleteById (id: string): Promise<void> { + return this.axiosApi.delete<string, void>(id) + } + + async add (deviceId: string, action: GenericAction): Promise<GenericAction> { + const url = '' + const data = this.serializer.convertModelToJsonApiData(action, deviceId) + const response = await this.axiosApi.post(url, { data }) + const savedAction = this.serializer.convertJsonApiObjectToModel(response.data) + // save every attachment as an GenericActionAttachment + if (savedAction.id) { + const promises = action.attachments.map((attachment: Attachment) => this.attachmentApi.add(savedAction.id as string, attachment)) + await Promise.all(promises) + } + return savedAction + } + + async update (deviceId: string, action: GenericAction): Promise<GenericAction> { + if (!action.id) { + throw new Error('no id for the GenericAction') + } + // load the stored action to get a list of the generic device action attachments before the update + const attRawResponse = await this.axiosApi.get(action.id, { + params: { + include: [ + 'generic_device_action_attachments.attachment' + ].join(',') + } + }) + const attResponseData = attRawResponse.data + const included = attResponseData.included + + // get the relations between attachments and generic device action attachments + const linkedAttachments: { [attachmentId: string]: string } = {} + if (included) { + const relations = this.serializer.convertJsonApiIncludedGenericActionAttachmentsToIdList(included) + // convert to object to gain faster access to its members + relations.forEach((rel) => { + linkedAttachments[rel.attachmentId] = rel.genericActionAttachmentId + }) + } + + // update the action + const data = this.serializer.convertModelToJsonApiData(action, deviceId) + const actionResponse = await this.axiosApi.patch(action.id, { data }) + + // find new attachments + const newAttachments: Attachment[] = [] + action.attachments.forEach((attachment: Attachment) => { + if (attachment.id && linkedAttachments[attachment.id]) { + return + } + newAttachments.push(attachment) + }) + + // find deleted attachments + const genericDeviceActionAttachmentsToDelete: string[] = [] + for (const attachmentId in linkedAttachments) { + if (action.attachments.find((i: Attachment) => i.id === attachmentId)) { + continue + } + genericDeviceActionAttachmentsToDelete.push(linkedAttachments[attachmentId]) + } + + // when there are no new attachments, newPromises is empty, which is okay + const newPromises = newAttachments.map((attachment: Attachment) => this.attachmentApi.add(action.id as string, attachment)) + // when there are no deleted attachments, deletedPromises is empty, which is okay + const deletedPromises = genericDeviceActionAttachmentsToDelete.map((id: string) => this.attachmentApi.delete(id)) + await Promise.all([...deletedPromises, ...newPromises]) + + return this.serializer.convertJsonApiObjectToModel(actionResponse.data) + } + + findRelatedGenericActionAttachments (actionId: string): Promise<GenericAction[]> { + const url = actionId + '/generic-device-action-attachments' + const params = { + 'page[size]': 10000, + include: 'attachment' + } + return this.axiosApi.get(url, { params }).then((rawServerResponse) => { + return this.serializer.convertJsonApiObjectListToModelList(rawServerResponse.data) + }) + } +} diff --git a/services/sms/GenericDeviceActionAttachmentApi.ts b/services/sms/GenericDeviceActionAttachmentApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..b812717b3f39c75822eedf5d13a083103ec917d8 --- /dev/null +++ b/services/sms/GenericDeviceActionAttachmentApi.ts @@ -0,0 +1,55 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { AxiosInstance } from 'axios' + +import { Attachment } from '@/models/Attachment' +import { IGenericActionAttachmentSerializer, GenericDeviceActionAttachmentSerializer } from '@/serializers/jsonapi/GenericActionAttachmentSerializer' + +export class GenericDeviceActionAttachmentApi { + private axiosApi: AxiosInstance + private serializer: IGenericActionAttachmentSerializer + + constructor (axiosInstance: AxiosInstance) { + this.axiosApi = axiosInstance + this.serializer = new GenericDeviceActionAttachmentSerializer() + } + + async add (actionId: string, attachment: Attachment): Promise<any> { + const url = '' + const data = this.serializer.convertModelToJsonApiData(attachment, actionId) + await this.axiosApi.post(url, { data }) + } + + async delete (id: string): Promise<void> { + return await this.axiosApi.delete<string, void>(id) + } +} diff --git a/test/modelUtils/Compareables.test.ts b/test/modelUtils/Compareables.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..13a3560bb25bde1103a03195ac5540d740e6de53 --- /dev/null +++ b/test/modelUtils/Compareables.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { DateTime } from 'luxon' +import { IDateCompareable, isDateCompareable, DateComparator } from '@/modelUtils/Compareables' + +describe('DateCompareables and DateComparator', () => { + describe('DateCompareable', () => { + describe('#isDateCompareable', () => { + it('should return true when an object implements a `date` property of type DateTime', () => { + const dateObject: IDateCompareable = { + date: DateTime.now() + } + expect(isDateCompareable(dateObject)).toBeTruthy() + }) + it('should return false when an object does not implement a `date` property', () => { + const falseObject = { + foo: 'bar' + } + // @ts-ignore + expect(isDateCompareable(falseObject)).toBeFalsy() + }) + it('should return false when an object implements a `date` property which is not of type DateTime', () => { + const simpleObject = { + date: 'foo' + } + // @ts-ignore + expect(isDateCompareable(simpleObject)).toBeFalsy() + }) + }) + }) + describe('DateComparator', () => { + describe('#compare', () => { + it('should return -1 when first argument is less than second argument', () => { + const a = { + date: DateTime.fromISO('2020-06-01T10:00:00.000') + } + const b = { + date: DateTime.fromISO('2020-06-02T10:00:00.000') + } + const comparator = new DateComparator() + + expect(comparator.compare(a, b)).toBe(-1) + }) + it('should return 1 when first argument is greater than second argument', () => { + const a = { + date: DateTime.fromISO('2020-06-02T10:00:00.000') + } + const b = { + date: DateTime.fromISO('2020-06-01T10:00:00.000') + } + const comparator = new DateComparator() + + expect(comparator.compare(a, b)).toBe(1) + }) + it('should return 0 when both arguments are equal', () => { + const a = { + date: DateTime.fromISO('2020-06-02T10:00:01.000') + } + const b = { + date: DateTime.fromISO('2020-06-02T10:00:01.000') + } + const comparator = new DateComparator() + + expect(comparator.compare(a, b)).toBe(0) + }) + }) + }) +}) diff --git a/test/models/GenericAction.test.ts b/test/models/GenericAction.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e54200bf21b1f07899c59b1914d5709a53a14b82 --- /dev/null +++ b/test/models/GenericAction.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { DateTime } from 'luxon' +import { Attachment } from '@/models/Attachment' +import { Contact } from '@/models/Contact' +import { GenericAction } from '@/models/GenericAction' + +describe('GenericAction', () => { + test('create a GenericAction from an object', () => { + const date1 = DateTime.fromISO('2021-05-27') + const date2 = DateTime.fromISO('2021-05-28') + + const attachment = new Attachment() + attachment.id = '1' + attachment.label = 'an attachment' + attachment.url = 'https://foo/baz' + + const contact = new Contact() + contact.givenName = 'Homer' + contact.familyName = 'Simpson' + contact.email = 'homer.simpson@springfield.com' + + const action = GenericAction.createFromObject({ + id: '1', + description: 'This is a generic action description', + actionTypeName: 'Generic Device Action', + actionTypeUrl: 'https://foo/bar', + beginDate: date1, + endDate: date2, + contact, + attachments: [attachment] + }) + + expect(typeof action).toBe('object') + expect(action).toHaveProperty('id', '1') + expect(action).toHaveProperty('description', 'This is a generic action description') + expect(action).toHaveProperty('actionTypeName', 'Generic Device Action') + expect(action).toHaveProperty('actionTypeUrl', 'https://foo/bar') + expect(action.beginDate).toBe(date1) + expect(action.endDate).toBe(date2) + expect(action.contact).toStrictEqual(contact) + expect(action.attachments).toContainEqual(attachment) + expect(action.isGenericAction).toBeTruthy() + }) +}) diff --git a/test/serializers/jsonapi/ActionTypeSerializer.test.ts b/test/serializers/jsonapi/ActionTypeSerializer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fdcc011494656e25b49e408beae34e11b6c5711 --- /dev/null +++ b/test/serializers/jsonapi/ActionTypeSerializer.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { ActionType } from '@/models/ActionType' +import { ActionTypeSerializer } from '@/serializers/jsonapi/ActionTypeSerializer' + +describe('ActionTypeSerializer', () => { + describe('#convertJsonApiObjectListToModelList', () => { + it('should convert a list of two elements to a model list', () => { + const jsonApiObjectList: any = { + data: [{ + attributes: { + term: 'Configuration Maintenance', + definition: 'A configuration (i.e. station) is maintained (i.e. check of functionality). configurationMaintenance actions can be followed by platformMaintenance or deviveMaintenance actions to be explicit.', + provenance: null, + provenance_uri: null, + category: null, + note: null, + status: 'ACCEPTED' + }, + id: '8', + links: { + self: 'http://rz-vm64.gfz-potsdam.de:5001/api/v1/actiontypes/8/' + }, + relationships: {}, + type: 'ActionType' + }, { + attributes: { + term: 'Configuration Observation', + definition: 'An observation is made at configuration level (i.e. station is covered by leafs). configurationObservation actions can be followed by platformObservation or deviveObservation actions to be explicit.', + provenance: null, + provenance_uri: null, + category: null, + note: null, + status: 'ACCEPTED' + }, + id: '9', + links: { + self: 'http://rz-vm64.gfz-potsdam.de:5001/api/v1/actiontypes/9/' + }, + relationships: {}, + type: 'ActionType' + }], + included: [], + jsonapi: { + version: '1.0' + }, + meta: { + count: 2 + } + } + + const expectedActionType1 = ActionType.createFromObject({ + id: '8', + name: 'Configuration Maintenance', + definition: 'A configuration (i.e. station) is maintained (i.e. check of functionality). configurationMaintenance actions can be followed by platformMaintenance or deviveMaintenance actions to be explicit.', + uri: 'http://rz-vm64.gfz-potsdam.de:5001/api/v1/actiontypes/8/' + }) + const expectedActionType2 = ActionType.createFromObject({ + id: '9', + name: 'Configuration Observation', + definition: 'An observation is made at configuration level (i.e. station is covered by leafs). configurationObservation actions can be followed by platformObservation or deviveObservation actions to be explicit.', + uri: 'http://rz-vm64.gfz-potsdam.de:5001/api/v1/actiontypes/9/' + }) + + const serializer = new ActionTypeSerializer() + + const actiontypes = serializer.convertJsonApiObjectListToModelList(jsonApiObjectList) + + expect(Array.isArray(actiontypes)).toBeTruthy() + expect(actiontypes.length).toEqual(2) + expect(actiontypes[0]).toEqual(expectedActionType1) + expect(actiontypes[1]).toEqual(expectedActionType2) + }) + }) +}) diff --git a/test/serializers/jsonapi/GenericActionAttachmentSerializer.test.ts b/test/serializers/jsonapi/GenericActionAttachmentSerializer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..951aeca4efde212c219eb0fdddd4b7a6e4b3f583 --- /dev/null +++ b/test/serializers/jsonapi/GenericActionAttachmentSerializer.test.ts @@ -0,0 +1,314 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { Attachment } from '@/models/Attachment' + +import { + GenericDeviceActionAttachmentSerializer, + GenericPlatformActionAttachmentSerializer +} from '@/serializers/jsonapi/GenericActionAttachmentSerializer' + +import { + IJsonApiEntityEnvelope, + IJsonApiEntityWithOptionalId, + IJsonApiEntityWithOptionalAttributes, + IJsonApiRelationships +} from '@/serializers/jsonapi/JsonApiTypes' + +describe('GenericActionAttachmentSerializer', () => { + describe('GenericDeviceActionAttachmentSerializer', () => { + function getExampleObjectResponse (): IJsonApiEntityEnvelope { + return { + data: { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/9/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '6' + }, + { + type: 'generic_device_action_attachment', + id: '7' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/9/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/9/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: null, + created_at: '2021-05-12T08:19:56.781661', + action_type_name: 'Device visit', + begin_date: '2021-05-21T00:00:00', + end_date: '2021-05-30T00:00:00', + action_type_uri: '', + description: 'dfdfdf' + }, + id: '9', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/9' + } + }, + links: { + self: '/rdm/svm-api/v1/generic-device-actions/9' + }, + included: [ + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/6/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/51' + }, + data: { + type: 'device_attachment', + id: '51' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/6/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/9' + } + } + }, + id: '6', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/6' + } + }, + { + type: 'device_attachment', + relationships: { + device: { + links: { + self: '/rdm/svm-api/v1/device-attachments/51/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + } + }, + attributes: { + url: 'https://foo.de', + label: 'Foo.de' + }, + id: '51', + links: { + self: '/rdm/svm-api/v1/device-attachments/51' + } + }, + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/7/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/52' + }, + data: { + type: 'device_attachment', + id: '52' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/7/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/9' + } + } + }, + id: '7', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/7' + } + }, + { + type: 'device_attachment', + relationships: { + device: { + links: { + self: '/rdm/svm-api/v1/device-attachments/52/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + } + }, + attributes: { + url: 'https://bar.baz', + label: 'Bar.baz' + }, + id: '52', + links: { + self: '/rdm/svm-api/v1/device-attachments/52' + } + } + ], + jsonapi: { + version: '1.0' + } + } + } + + describe('constructing and types', () => { + it('should return \'device\' as its type', () => { + const serializer = new GenericDeviceActionAttachmentSerializer() + expect(serializer.targetType).toEqual('device') + }) + it('should return a correct action type name', () => { + const serializer = new GenericDeviceActionAttachmentSerializer() + expect(serializer.getActionTypeName()).toEqual('generic_device_action') + }) + it('should return a correct action attachment type name', () => { + const serializer = new GenericDeviceActionAttachmentSerializer() + expect(serializer.getActionAttachmentTypeName()).toEqual('generic_device_action_attachment') + }) + it('should return a the plural form of the action attachment type name', () => { + const serializer = new GenericDeviceActionAttachmentSerializer() + expect(serializer.getActionAttachmentTypeNamePlural()).toEqual('generic_device_action_attachments') + }) + it('should return a correct attachment type name', () => { + const serializer = new GenericDeviceActionAttachmentSerializer() + expect(serializer.getAttachmentTypeName()).toEqual('device_attachment') + }) + }) + describe('#convertModelToJsonApiData', () => { + it('should return a JSON API object from an attachment and an action id', () => { + const attachment = Attachment.createFromObject({ + id: '1', + label: 'Foo', + url: 'https://bar.baz' + }) + const actionId = '2' + + const expectedApiModel: IJsonApiEntityWithOptionalId = { + type: 'generic_device_action_attachment', + attributes: {}, + relationships: { + action: { + data: { + type: 'generic_device_action', + id: '2' + } + }, + attachment: { + data: { + type: 'device_attachment', + id: '1' + } + } + } + } + + const serializer = new GenericDeviceActionAttachmentSerializer() + const apiModel = serializer.convertModelToJsonApiData(attachment, actionId) + + expect(apiModel).toEqual(expectedApiModel) + }) + }) + describe('#convertJsonApiRelationshipsModelList', () => { + it('should return a serialized list of attachments from an list of included API entities', () => { + const attachment1 = Attachment.createFromObject({ + id: '51', + label: 'Foo.de', + url: 'https://foo.de' + }) + const attachment2 = Attachment.createFromObject({ + id: '52', + label: 'Bar.baz', + url: 'https://bar.baz' + }) + + const response = getExampleObjectResponse() + const serializer = new GenericDeviceActionAttachmentSerializer() + + const attachmentList = serializer.convertJsonApiRelationshipsModelList(response.data.relationships as IJsonApiRelationships, response.included as IJsonApiEntityWithOptionalAttributes[]) + + expect(attachmentList).toHaveLength(2) + expect(attachmentList).toContainEqual(attachment1) + expect(attachmentList).toContainEqual(attachment2) + }) + }) + }) + describe('GenericPlatformActionAttachmentSerializer', () => { + describe('constructing and types', () => { + it('should return \'platform\' as its type', () => { + const serializer = new GenericPlatformActionAttachmentSerializer() + expect(serializer.targetType).toEqual('platform') + }) + it('should return a correct action type name', () => { + const serializer = new GenericPlatformActionAttachmentSerializer() + expect(serializer.getActionTypeName()).toEqual('generic_platform_action') + }) + it('should return a correct action attachment type name', () => { + const serializer = new GenericPlatformActionAttachmentSerializer() + expect(serializer.getActionAttachmentTypeName()).toEqual('generic_platform_action_attachment') + }) + it('should return a the plural form of the action attachment type name', () => { + const serializer = new GenericPlatformActionAttachmentSerializer() + expect(serializer.getActionAttachmentTypeNamePlural()).toEqual('generic_platform_action_attachments') + }) + it('should return a correct attachment type name', () => { + const serializer = new GenericPlatformActionAttachmentSerializer() + expect(serializer.getAttachmentTypeName()).toEqual('platform_attachment') + }) + }) + }) +}) diff --git a/test/serializers/jsonapi/GenericActionSerializer.test.ts b/test/serializers/jsonapi/GenericActionSerializer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44c487d53120d5ebc0fe102a852cf86f4f2e591a --- /dev/null +++ b/test/serializers/jsonapi/GenericActionSerializer.test.ts @@ -0,0 +1,953 @@ +/** + * @license + * Web client of the Sensor Management System software developed within + * the Helmholtz DataHub Initiative by GFZ and UFZ. + * + * Copyright (C) 2020, 2021 + * - Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) + * - Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) + * - Helmholtz Centre Potsdam - GFZ German Research Centre for + * Geosciences (GFZ, https://www.gfz-potsdam.de) + * + * Parts of this program were developed within the context of the + * following publicly funded projects or measures: + * - Helmholtz Earth and Environment DataHub + * (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + * + * Licensed under the HEESIL, Version 1.0 or - as soon they will be + * approved by the "Community" - subsequent versions of the HEESIL + * (the "Licence"). + * + * You may not use this work except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://gitext.gfz-potsdam.de/software/heesil + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Licence for the specific language governing + * permissions and limitations under the Licence. + */ +import { DateTime } from 'luxon' + +import { Attachment } from '@/models/Attachment' +import { GenericAction } from '@/models/GenericAction' +import { Contact } from '@/models/Contact' + +import { + GenericDeviceActionSerializer, + GenericPlatformActionSerializer +} from '@/serializers/jsonapi/GenericActionSerializer' + +import { + IJsonApiEntityEnvelope, + IJsonApiEntityListEnvelope, + IJsonApiEntityWithOptionalId, + IJsonApiEntityWithOptionalAttributes, + IJsonApiRelationships +} from '@/serializers/jsonapi/JsonApiTypes' + +describe('GenericActionSerializer', () => { + function getExampleObjectResponse (): IJsonApiEntityEnvelope { + return { + data: { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/7/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '21' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: '2021-05-27T07:08:35.964720', + created_at: '2021-05-07T09:57:38.203251', + action_type_name: 'Device maintainance', + begin_date: '2021-05-23T00:00:00', + end_date: '2021-06-01T00:00:00', + action_type_uri: '', + description: 'Bla' + }, + id: '7', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7' + } + }, + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7' + }, + included: [ + { + type: 'contact', + relationships: { + platforms: { + links: { + related: '/rdm/svm-api/v1/contacts/14/relationships/platforms' + }, + data: [ + + ] + }, + user: { + links: { + self: '/rdm/svm-api/v1/contacts/14/relationships/user' + }, + data: { + type: 'user', + id: '6' + } + }, + configurations: { + links: { + related: '/rdm/svm-api/v1/contacts/14/relationships/configurations' + }, + data: [ + + ] + }, + devices: { + links: { + related: '/rdm/svm-api/v1/contacts/14/relationships/devices' + }, + data: [ + { + type: 'device', + id: '250' + } + ] + } + }, + attributes: { + family_name: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '', + given_name: 'Marc' + }, + id: '14', + links: { + self: '/rdm/svm-api/v1/contacts/14' + } + } + ], + jsonapi: { + version: '1.0' + } + } + } + + function getExampleObjectListResponse (): IJsonApiEntityListEnvelope { + return { + data: [ + { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/4/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '1' + }, + { + type: 'generic_device_action_attachment', + id: '2' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: null, + created_at: '2021-05-07T09:11:59.289773', + action_type_name: 'Device maintainance', + begin_date: '2021-05-03T00:00:00', + end_date: '2021-05-04T00:00:00', + action_type_uri: '', + description: 'yet another maintainance' + }, + id: '4', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4' + } + }, + { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/5/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '3' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/5/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/5/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: null, + created_at: '2021-05-07T09:23:53.678558', + action_type_name: 'Device visit', + begin_date: '2021-05-09T00:00:00', + end_date: '2021-05-12T00:00:00', + action_type_uri: '', + description: 'Site was nice!' + }, + id: '5', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/5' + } + } + ], + links: { + self: 'http://rz-vm64.gfz-potsdam.de:5000/rdm/svm-api/v1/generic-device-actions?page%5Bsize%5D=10000&include=contact%2Cgeneric_device_action_attachments.attachment' + }, + included: [ + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/1/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/51' + }, + data: { + type: 'device_attachment', + id: '51' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/1/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/4' + } + } + }, + id: '1', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/1' + } + }, + { + type: 'device_attachment', + relationships: { + device: { + links: { + self: '/rdm/svm-api/v1/device-attachments/51/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + } + }, + attributes: { + url: 'https://foo.de', + label: 'Foo.de' + }, + id: '51', + links: { + self: '/rdm/svm-api/v1/device-attachments/51' + } + }, + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/2/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/52' + }, + data: { + type: 'device_attachment', + id: '52' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/2/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/4' + } + } + }, + id: '2', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/2' + } + }, + { + type: 'device_attachment', + relationships: { + device: { + links: { + self: '/rdm/svm-api/v1/device-attachments/52/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + } + }, + attributes: { + url: 'https://bar.baz', + label: 'Bar.baz' + }, + id: '52', + links: { + self: '/rdm/svm-api/v1/device-attachments/52' + } + }, + { + type: 'contact', + relationships: { + user: { + links: { + self: '/rdm/svm-api/v1/contacts/14/relationships/user' + }, + data: { + type: 'user', + id: '6' + } + } + }, + attributes: { + family_name: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '', + given_name: 'Marc' + }, + id: '14', + links: { + self: '/rdm/svm-api/v1/contacts/14' + } + }, + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/3/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/52' + }, + data: { + type: 'device_attachment', + id: '52' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/3/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/5' + } + } + }, + id: '3', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/3' + } + } + ], + meta: { + count: 2 + }, + jsonapi: { + version: '1.0' + } + } + } + + function getExampleDeviceResponse (): IJsonApiEntityEnvelope { + return { + data: { + type: 'device', + attributes: { + persistent_identifier: null, + model: 'SM1', + website: 'http://www.adcon.com', + short_name: 'Adcon SM1 soil moisture / temperature sensor FTDR Zeitlow 1', + serial_number: '', + updated_at: '2021-04-26T09:03:01.944689', + long_name: 'Adcon SM 1 soil moisture / temperature sensor', + device_type_uri: '', + status_name: 'In Use', + dual_use: false, + device_type_name: 'Frequency/Time Domain Reflectometer (FTDR)(Soil moisture and temperature)', + description: '', + inventory_number: '', + manufacturer_name: 'OTT Hydromet GmbH', + created_at: '2021-01-18T07:07:24.360000', + manufacturer_uri: 'OTT Hydromet GmbH', + status_uri: 'http://rz-vm64.gfz-potsdam.de:8000/api/v1/equipmentstatus/2/' + }, + relationships: { + generic_device_actions: { + links: { + related: '/rdm/svm-api/v1/devices/204/relationships/generic-device-actions' + }, + data: [ + { + type: 'generic_device_action', + id: '3' + }, + { + type: 'generic_device_action', + id: '4' + } + ] + } + }, + id: '204', + links: { + self: '/rdm/svm-api/v1/devices/204' + } + }, + links: { + self: '/rdm/svm-api/v1/devices/204' + }, + included: [ + { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/3/relationships/generic-device-action-attachments' + }, + data: [ + + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/3/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/3/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: null, + created_at: '2021-05-06T12:54:18.899177', + action_type_name: 'Device maintainance', + begin_date: '2021-05-01T00:00:00', + end_date: '2021-05-03T00:00:00', + action_type_uri: '', + description: 'Rasenmähen' + }, + id: '3', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/3' + } + }, + { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/4/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '1' + }, + { + type: 'generic_device_action_attachment', + id: '2' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: null, + created_at: '2021-05-07T09:11:59.289773', + action_type_name: 'Device maintainance', + begin_date: '2021-05-03T00:00:00', + end_date: '2021-05-04T00:00:00', + action_type_uri: '', + description: 'yet another maintainance' + }, + id: '4', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/4' + } + } + ], + jsonapi: { + version: '1.0' + } + } + } + + function getExampleObjectResponseWithIncludedActionAttachments (): IJsonApiEntityEnvelope { + return { + data: { + type: 'generic_device_action', + relationships: { + generic_device_action_attachments: { + links: { + related: '/rdm/svm-api/v1/generic-device-actions/7/relationships/generic-device-action-attachments' + }, + data: [ + { + type: 'generic_device_action_attachment', + id: '21' + } + ] + }, + device: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + }, + contact: { + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7/relationships/contact', + related: '/rdm/svm-api/v1/contacts/14' + }, + data: { + type: 'contact', + id: '14' + } + } + }, + attributes: { + updated_at: '2021-05-28T11:12:54.938479', + created_at: '2021-05-07T09:57:38.203251', + action_type_name: 'Device maintainance', + begin_date: '2021-05-23T00:00:00', + end_date: '2021-06-01T00:00:00', + action_type_uri: '', + description: 'Bla' + }, + id: '7', + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7' + } + }, + links: { + self: '/rdm/svm-api/v1/generic-device-actions/7' + }, + included: [ + { + type: 'generic_device_action_attachment', + relationships: { + attachment: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/21/relationships/attachment', + related: '/rdm/svm-api/v1/device-attachments/51' + }, + data: { + type: 'device_attachment', + id: '51' + } + }, + action: { + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/21/relationships/action', + related: '/rdm/svm-api/v1/generic-device-actions/7' + } + } + }, + id: '21', + links: { + self: '/rdm/svm-api/v1/generic-device-action-attachments/21' + } + }, + { + type: 'device_attachment', + relationships: { + device: { + links: { + self: '/rdm/svm-api/v1/device-attachments/51/relationships/device', + related: '/rdm/svm-api/v1/devices/204' + }, + data: { + type: 'device', + id: '204' + } + } + }, + attributes: { + url: 'https://foo.de', + label: 'Foo.de' + }, + id: '51', + links: { + self: '/rdm/svm-api/v1/device-attachments/51' + } + } + ], + jsonapi: { + version: '1.0' + } + } + } + + describe('GenericDeviceActionSerializer', () => { + describe('constructing and types', () => { + it('should return \'device\' as its type', () => { + const serializer = new GenericDeviceActionSerializer() + expect(serializer.targetType).toEqual('device') + }) + it('should return a correct action type name', () => { + const serializer = new GenericDeviceActionSerializer() + expect(serializer.getActionTypeName()).toEqual('generic_device_action') + }) + it('should return a the plural form of the action type name', () => { + const serializer = new GenericDeviceActionSerializer() + expect(serializer.getActionTypeNamePlural()).toEqual('generic_device_actions') + }) + it('should return a correction action attachment type name', () => { + const serializer = new GenericDeviceActionSerializer() + expect(serializer.getActionAttachmentTypeName()).toEqual('generic_device_action_attachment') + }) + }) + describe('#convertJsonApiObjectToModel', () => { + it('should return a serialized generic action from an API response', () => { + const contact = Contact.createFromObject({ + id: '14', + givenName: 'Marc', + familyName: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '' + }) + const expectedAction = new GenericAction() + expectedAction.id = '7' + expectedAction.description = 'Bla' + expectedAction.actionTypeName = 'Device maintainance' + expectedAction.actionTypeUrl = '' + expectedAction.beginDate = DateTime.fromISO('2021-05-23T00:00:00', { zone: 'UTC' }) + expectedAction.endDate = DateTime.fromISO('2021-06-01T00:00:00', { zone: 'UTC' }) + expectedAction.contact = contact + + const serializer = new GenericDeviceActionSerializer() + const action = serializer.convertJsonApiObjectToModel(getExampleObjectResponse()) + + expect(action).toEqual(expectedAction) + }) + }) + describe('#convertJsonApiDataToModel', () => { + it('should return a serialized generic action from an API response object', () => { + const contact = Contact.createFromObject({ + id: '14', + givenName: 'Marc', + familyName: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '' + }) + const expectedAction = new GenericAction() + expectedAction.id = '7' + expectedAction.description = 'Bla' + expectedAction.actionTypeName = 'Device maintainance' + expectedAction.actionTypeUrl = '' + expectedAction.beginDate = DateTime.fromISO('2021-05-23T00:00:00', { zone: 'UTC' }) + expectedAction.endDate = DateTime.fromISO('2021-06-01T00:00:00', { zone: 'UTC' }) + expectedAction.contact = contact + + const serializer = new GenericDeviceActionSerializer() + const data = getExampleObjectResponse().data + const included = getExampleObjectResponse().included + const action = serializer.convertJsonApiDataToModel(data, included as IJsonApiEntityWithOptionalAttributes[]) + + expect(action).toEqual(expectedAction) + }) + }) + describe('#convertJsonApiRelationshipsModelList', () => { + it('should return a serialized list of generic actions from an list of included API entities', () => { + const serializer = new GenericDeviceActionSerializer() + const response = getExampleDeviceResponse() + + const relationships = response.data.relationships + const included = response.included + + const expectedAction1 = new GenericAction() + expectedAction1.id = '3' + expectedAction1.description = 'Rasenmähen' + expectedAction1.actionTypeName = 'Device maintainance' + expectedAction1.actionTypeUrl = '' + expectedAction1.beginDate = DateTime.fromISO('2021-05-01T00:00:00', { zone: 'UTC' }) + expectedAction1.endDate = DateTime.fromISO('2021-05-03T00:00:00', { zone: 'UTC' }) + + const expectedAction2 = new GenericAction() + expectedAction2.id = '4' + expectedAction2.description = 'yet another maintainance' + expectedAction2.actionTypeName = 'Device maintainance' + expectedAction2.actionTypeUrl = '' + expectedAction2.beginDate = DateTime.fromISO('2021-05-03T00:00:00', { zone: 'UTC' }) + expectedAction2.endDate = DateTime.fromISO('2021-05-04T00:00:00', { zone: 'UTC' }) + + const actionList = serializer.convertJsonApiRelationshipsModelList(relationships as IJsonApiRelationships, included as IJsonApiEntityWithOptionalAttributes[]) + + expect(actionList).toHaveProperty('genericDeviceActions') + expect(actionList.genericDeviceActions).toContainEqual(expectedAction1) + expect(actionList.genericDeviceActions).toContainEqual(expectedAction2) + }) + }) + describe('#convertJsonApiObjectListToModelList', () => { + it('should return a list of serialized generic action from an API response', () => { + const contact = Contact.createFromObject({ + id: '14', + givenName: 'Marc', + familyName: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '' + }) + const attachment1 = Attachment.createFromObject({ + id: '51', + label: 'Foo.de', + url: 'https://foo.de' + }) + const attachment2 = Attachment.createFromObject({ + id: '52', + label: 'Bar.baz', + url: 'https://bar.baz' + }) + const expectedAction1 = new GenericAction() + expectedAction1.id = '4' + expectedAction1.description = 'yet another maintainance' + expectedAction1.actionTypeName = 'Device maintainance' + expectedAction1.actionTypeUrl = '' + expectedAction1.beginDate = DateTime.fromISO('2021-05-03T00:00:00', { zone: 'UTC' }) + expectedAction1.endDate = DateTime.fromISO('2021-05-04T00:00:00', { zone: 'UTC' }) + expectedAction1.contact = contact + expectedAction1.attachments = [ + attachment1, + attachment2 + ] + + const expectedAction2 = new GenericAction() + expectedAction2.id = '5' + expectedAction2.description = 'Site was nice!' + expectedAction2.actionTypeName = 'Device visit' + expectedAction2.actionTypeUrl = '' + expectedAction2.beginDate = DateTime.fromISO('2021-05-09T00:00:00', { zone: 'UTC' }) + expectedAction2.endDate = DateTime.fromISO('2021-05-12T00:00:00', { zone: 'UTC' }) + expectedAction2.contact = contact + expectedAction2.attachments = [ + attachment2 + ] + + const serializer = new GenericDeviceActionSerializer() + const actionList = serializer.convertJsonApiObjectListToModelList(getExampleObjectListResponse()) + + expect(actionList).toContainEqual(expectedAction1) + expect(actionList).toContainEqual(expectedAction2) + }) + }) + describe('#convertModelToJsonApiData', () => { + it('should return a JSON API representation from a generic action model', () => { + const contact = Contact.createFromObject({ + id: '14', + givenName: 'Marc', + familyName: 'Hanisch', + email: 'marc.hanisch@gfz-potsdam.de', + website: '' + }) + + const action = new GenericAction() + action.id = '7' + action.description = 'Bla' + action.actionTypeName = 'Device maintainance' + action.actionTypeUrl = '' + action.beginDate = DateTime.fromISO('2021-05-23T00:00:00', { zone: 'UTC' }) + action.endDate = DateTime.fromISO('2021-06-01T00:00:00', { zone: 'UTC' }) + action.contact = contact + + const expectedApiModel: IJsonApiEntityWithOptionalId = { + type: 'generic_device_action', + id: '7', + attributes: { + description: 'Bla', + action_type_name: 'Device maintainance', + action_type_uri: '', + begin_date: '2021-05-23T00:00:00.000Z', + end_date: '2021-06-01T00:00:00.000Z' + }, + relationships: { + device: { + data: { + type: 'device', + id: '204' + } + }, + contact: { + data: { + type: 'contact', + id: '14' + } + } + } + } + + const serializer = new GenericDeviceActionSerializer() + const apiModel = serializer.convertModelToJsonApiData(action, '204') + + expect(apiModel).toEqual(expectedApiModel) + }) + }) + describe('#convertModelToJsonApiRelationshipObject', () => { + it('should return a JSON API relationships object from a generic action model', () => { + const action = new GenericAction() + action.id = '7' + + const expectedRelationship: IJsonApiRelationships = { + generic_device_action: { + data: { + id: '7', + type: 'generic_device_action' + } + } + } + const serializer = new GenericDeviceActionSerializer() + const apiRelationship = serializer.convertModelToJsonApiRelationshipObject(action) + + expect(apiRelationship).toEqual(expectedRelationship) + }) + }) + describe('#convertJsonApiIncludedGenericActionAttachmentsToIdList', () => { + it('should return a list of generic_device_action_attachment ids / attachment ids mappings', () => { + const expectedMappings = [ + { + genericActionAttachmentId: '21', + attachmentId: '51' + } + ] + const serializer = new GenericDeviceActionSerializer() + const data = getExampleObjectResponseWithIncludedActionAttachments() + const mappings = serializer.convertJsonApiIncludedGenericActionAttachmentsToIdList(data.included as IJsonApiEntityWithOptionalAttributes[]) + + expect(mappings).toEqual(expectedMappings) + }) + }) + }) + + describe('GenericPlatformActionSerializer', () => { + describe('constructing and types', () => { + it('should return \'platform\' as its type', () => { + const serializer = new GenericPlatformActionSerializer() + expect(serializer.targetType).toEqual('platform') + }) + it('should return a correct action type name', () => { + const serializer = new GenericPlatformActionSerializer() + expect(serializer.getActionTypeName()).toEqual('generic_platform_action') + }) + it('should return a the plural form of the action type name', () => { + const serializer = new GenericPlatformActionSerializer() + expect(serializer.getActionTypeNamePlural()).toEqual('generic_platform_actions') + }) + it('should return a correction action attachment type name', () => { + const serializer = new GenericPlatformActionSerializer() + expect(serializer.getActionAttachmentTypeName()).toEqual('generic_platform_action_attachment') + }) + }) + }) +}) diff --git a/utils/dateHelper.ts b/utils/dateHelper.ts index 832dc9a105df55b70276b4533c20e9567d71b8ca..35d73f356a714b8b5286d294a19816fc18c7a34f 100644 --- a/utils/dateHelper.ts +++ b/utils/dateHelper.ts @@ -50,3 +50,10 @@ export const timeStampToUTCDateTime = (value: number) : string => { const date = DateTime.fromSeconds(value).setZone('UTC') return date.toFormat('yyyy-MM-dd HH:mm:ss') + ' UTC' } + +export const dateToDateTimeString = (aDate: DateTime | null): string => { + if (!aDate) { + return '' + } + return aDate.setZone('UTC').toFormat('yyyy-MM-dd HH:mm:ss') +}