Select Git revision
grpc_csharp_plugin.exe
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
FormMetadata.vue 26.52 KiB
<template>
<div>
<!-- Copy Metadata -->
<CoscineFormGroup
v-if="parentProject"
:mandatory="false"
label-for="CopyData"
:label="
$t('form.project.copyMetadataLabel', {
project: parentProject.name,
})
"
:is-loading="isLoading"
type="button"
>
<b-button
id="project_copy_button"
variant="secondary"
size="sm"
:disabled="disabled"
@click.prevent="copyMetadataFromParent"
>
{{ $t("buttons.copyMetadata") }}
</b-button>
</CoscineFormGroup>
<!-- Principal Investigators -->
<CoscineFormGroup
:mandatory="true"
label-for="PrincipleInvestigators"
:label="$t('form.project.projectPrincipleInvestigatorsLabel')"
:is-loading="isLoading"
type="input"
>
<!-- number of characters -->
<template #hint>
{{ projectForManipulation.principleInvestigators?.length ?? 0 }} /
{{
v$.projectForManipulation.principleInvestigators.maxLength.$params.max
}}
</template>
<b-form-input
id="PrincipleInvestigators"
v-model="v$.projectForManipulation.principleInvestigators.$model"
:state="
v$.projectForManipulation.principleInvestigators.$dirty
? !v$.projectForManipulation.principleInvestigators.$error
: null
"
:placeholder="$t('form.project.projectPrincipleInvestigators')"
:maxlength="
v$.projectForManipulation.principleInvestigators.maxLength.$params.max
"
required
:disabled="disabled"
/>
<div class="invalid-tooltip">
{{
$t("form.project.projectPrincipleInvestigatorsHelp", {
maxLength:
v$.projectForManipulation.principleInvestigators.maxLength.$params
.max,
})
}}
</div>
</CoscineFormGroup>
<!-- Project Start -->
<CoscineFormGroup
:mandatory="true"
label-for="StartDate"
:label="$t('form.project.projectStartLabel')"
:is-loading="isLoading"
type="input"
>
<b-form-input
id="StartDate"
v-model="v$.projectForManipulation.startDate.$model"
type="date"
:locale="$i18n.locale"
:disabled="disabled"
:state="
v$.projectForManipulation.startDate.$dirty
? !v$.projectForManipulation.startDate.$error
: null
"
required
:placeholder="$t('form.project.projectStart')"
/>
</CoscineFormGroup>
<!-- Project End -->
<CoscineFormGroup
:mandatory="true"
label-for="EndDate"
:label="$t('form.project.projectEndLabel')"
:is-loading="isLoading"
type="input"
>
<b-form-input
id="EndDate"
v-model="v$.projectForManipulation.endDate.$model"
type="date"
:locale="$i18n.locale"
:min="projectForManipulation.startDate"
:disabled="disabled"
:state="
v$.projectForManipulation.endDate.$dirty
? !v$.projectForManipulation.endDate.$error
: null
"
required
:placeholder="$t('form.project.projectEnd')"
/>
</CoscineFormGroup>
<!-- Discipline -->
<CoscineFormGroup
:mandatory="true"
label-for="Discipline"
:label="$t('form.project.projectDisciplineLabel')"
:is-loading="isLoading"
type="input"
>
<multiselect
id="Discipline"
v-model="v$.projectForManipulation.disciplines.$model"
:disabled="disabled"
:options="disciplines"
:multiple="true"
:hide-selected="true"
:label="disciplineLabel"
:track-by="disciplineLabel"
:placeholder="$t('form.project.projectDiscipline')"
:show-labels="false"
:class="{
invalid: v$.projectForManipulation.disciplines.$dirty
? v$.projectForManipulation.disciplines.$invalid
: false,
}"
>
<template #singleLabel="entities">
<div :disabled="disabled">
{{ entities.option[disciplineLabel] }}
</div>
</template>
<template #option="entities">
<div :disabled="disabled">
{{ entities.option[disciplineLabel] }}
</div>
</template>
</multiselect>
</CoscineFormGroup>
<!-- Responsible Organization -->
<CoscineFormGroup
:mandatory="true"
label-for="ResponsibleOrganization"
:label="$t('form.project.projectResponsibleOrganizationLabel')"
:is-loading="isLoading"
type="input"
info
>
<!-- Hint -->
<template #hint>
{{ $t("form.project.projectOrganizationHint") }}
</template>
<template #popover>
{{ $t("form.project.projectOrganizationLabelPopover") }}
<b-link
:href="
$t('form.project.projectOrganizationLabelPopoverUrl').toString()
"
target="_blank"
>
{{ $t("default.help") }}
</b-link>
</template>
<!-- Rely on the API for filtering, disable internal component search -->
<multiselect
id="ResponsibleOrganization"
v-model="responsibleOrganization"
:disabled="disabled"
:options="organizations"
:multiple="false"
:loading="isLoadingOrganizations"
:hide-selected="true"
label="displayName"
track-by="uri"
:show-labels="false"
:class="{
invalid: v$.projectForManipulation.organizations.$dirty
? v$.projectForManipulation.organizations.$invalid
: false,
}"
:internal-search="false"
:searchable="true"
:placeholder="$t('form.project.projectResponsibleOrganization')"
@search-change="retrieveMoreOrganizations"
>
<template #afterList>
<div
v-if="organizations.length && !organizationsComplete"
v-observe-visibility="retrieveMoreOrganizations"
>
<div class="d-flex justify-content-left my-3 ps-3">
<b-spinner v-if="isLoadingOrganizations" small />
</div>
</div>
</template>
<template #noOptions>
{{ $t("form.project.projectOrganizationNoOptions") }}
</template>
<template #noResult>
{{ $t("form.project.projectOrganizationNoResult") }}
</template>
</multiselect>
</CoscineFormGroup>
<!-- Additional Organizations -->
<CoscineFormGroup
:mandatory="false"
label-for="AdditionalOrganization"
:label="$t('form.project.projectAdditionalOrganizationLabel')"
:is-loading="isLoading"
type="input"
>
<multiselect
id="AdditionalOrganization"
v-model="additionalOrganizations"
:disabled="disabled"
:options="organizations"
:multiple="true"
:loading="isLoadingOrganizations"
:hide-selected="true"
label="displayName"
track-by="uri"
:show-labels="false"
:placeholder="$t('form.project.projectAdditionalOrganization')"
:class="{
invalid: v$.projectForManipulation.organizations.$dirty
? v$.projectForManipulation.organizations.$invalid
: false,
}"
:internal-search="false"
:searchable="true"
@search-change="retrieveMoreOrganizations"
>
<template #afterList>
<div
v-if="organizations.length && !organizationsComplete"
v-observe-visibility="retrieveMoreOrganizations"
>
<div class="d-flex justify-content-left my-3 ps-3">
<b-spinner v-if="isLoadingOrganizations" small />
</div>
</div>
</template>
<template #noOptions>
{{ $t("form.project.projectOrganizationNoOptions") }}
</template>
<template #noResult>
{{ $t("form.project.projectOrganizationNoResult") }}
</template>
</multiselect>
</CoscineFormGroup>
<!-- Project Keywords -->
<CoscineFormGroup
label-for="Keywords"
:label="$t('form.project.projectKeywordsLabel')"
:is-loading="isLoading"
>
<multiselect
id="Keywords"
v-model="v$.projectForManipulation.keywords.$model"
:options="projectForManipulation.keywords"
:placeholder="$t('form.project.projectKeywordsPlaceholder')"
:multiple="true"
:taggable="true"
:max="limitKeywords(projectForManipulation.keywords)"
:tag-placeholder="$t('form.project.tagPlaceholder')"
:disabled="disabled"
:show-labels="false"
@tag="addTag"
@remove="v$.projectForManipulation.keywords.$touch()"
>
<template #maxElements>
{{
$t("form.project.projectKeywordsHelp", {
maxLength:
v$.projectForManipulation.keywords.maxLength.$params.max,
})
}}
</template>
<template #noOptions>
{{
$t("form.project.projectKeywordsEmpty", {
maxLength:
v$.projectForManipulation.keywords.maxLength.$params.max,
})
}}
</template>
</multiselect>
</CoscineFormGroup>
<!-- Visibility -->
<CoscineFormGroup
:mandatory="true"
label-for="Visibility"
:label="$t('form.project.projectMetadataVisibilityLabel')"
:is-loading="isLoading"
type="input"
info
>
<template #popover>
{{ $t("form.project.projectMetadataVisibilityLabelPopover") }}
<b-link
:href="
$t(
'form.project.projectMetadataVisibilityLabelPopoverUrl',
).toString()
"
target="_blank"
>
{{ $t("default.help") }}
</b-link>
</template>
<b-form-radio-group
v-model="selectedVisibility"
name="radios-stacked"
:options="visibilities"
text-field="displayName"
value-field="id"
track-by="id"
stacked
:disabled="disabled"
@update:modelValue="setVisibility(selectedVisibility)"
/>
</CoscineFormGroup>
<!-- Grant ID -->
<CoscineFormGroup
label-for="GrantId"
:label="$t('form.project.projectGrantIdLabel')"
:is-loading="isLoading"
type="input"
>
<!-- number of characters -->
<template #hint>
{{ projectForManipulation.grantId?.length ?? 0 }} /
{{ v$.projectForManipulation.grantId.maxLength.$params.max }}
</template>
<b-form-input
id="GrantId"
v-model="v$.projectForManipulation.grantId.$model"
:state="
v$.projectForManipulation.grantId.$dirty
? !v$.projectForManipulation.grantId.$error
: null
"
:placeholder="$t('form.project.projectGrantId')"
:maxlength="v$.projectForManipulation.grantId.maxLength.$params.max"
required
:disabled="disabled"
/>
<div class="invalid-tooltip">
{{
$t("form.project.projectGrantIdHelp", {
maxLength: v$.projectForManipulation.grantId.maxLength.$params.max,
})
}}
</div>
</CoscineFormGroup>
</div>
</template>
<script lang="ts" setup>
import { required, maxLength, helpers } from "@vuelidate/validators";
import { useVuelidate, type BaseValidation } from "@vuelidate/core";
import {
DisciplineDto2DisciplineForProjectManipulationDto,
projectMapper,
OrganizationDto2OrganizationForProjectManipulationDto,
VisibilityDto2VisibilityForProjectManipulationDto,
} from "@/mapping/project";
import moment from "moment";
import { OrganizationForProjectManipulation } from "@/models/OrganizationForProjectManipulation";
import { ProjectForUpdate } from "@/models/ProjectForUpdate";
import { ProjectForCreation } from "@/models/ProjectForCreation";
// import the store for current module
import useProjectStore from "../../store";
import type {
AcceptedLanguage,
ProjectDto,
OrganizationForProjectManipulationDto,
} from "@coscine/api-client/dist/types/Coscine.Api";
import { useI18n } from "vue-i18n";
const i18n = useI18n();
const projectForManipulation = defineModel<
ProjectForUpdate | ProjectForCreation
>({
required: true,
});
const props = defineProps({
currentProject: {
default: null,
type: [Object, null] as PropType<ProjectDto | null>,
required: false,
},
parentProject: {
default: null,
type: [Object, null] as PropType<ProjectDto | null>,
required: false,
},
disabled: {
default: false,
type: Boolean,
},
isLoading: {
default: false,
type: Boolean,
},
});
const emit = defineEmits<{
validation: [_: BaseValidation<typeof projectForManipulation.value>];
}>();
const projectStore = useProjectStore();
const responsibleOrganization = ref<OrganizationForProjectManipulation | null>(
null,
);
const additionalOrganizations = ref<OrganizationForProjectManipulation[]>([]);
/*
Definition of the validation rules and initial state
will enable proper typings in the code
*/
// Custom validator to check for exactly one responsible organization
const organizationsHasOneResponsible = helpers.withMessage(
"There must be exactly one responsible organization",
(value: OrganizationForProjectManipulationDto[]) => {
if (!value || !Array.isArray(value)) return false;
const countResponsible = value.filter((org) => org.responsible).length;
return countResponsible === 1;
},
);
// Custom validator to ensure all organizations have unique URIs
const uniqueOrganizationUris = helpers.withMessage(
"Organizations must have unique URIs",
(value: OrganizationForProjectManipulationDto[]) => {
if (!value || !Array.isArray(value)) return true;
const uris = value.map((org) => org.uri);
const uniqueUris = new Set(uris);
return uniqueUris.size === uris.length;
},
);
const state = reactive({
projectForManipulation: projectForManipulation,
});
const rules = {
projectForManipulation: {
principleInvestigators: { required, maxLength: maxLength(500) },
startDate: { required },
endDate: { required },
disciplines: { required },
organizations: {
required,
organizationsHasOneResponsible,
uniqueOrganizationUris,
},
keywords: { maxLength: maxLength(1000) },
visibility: { required },
grantId: { maxLength: maxLength(500) },
},
};
const v$ = useVuelidate(rules, state);
const isLoadingOrganizations = ref(false);
const selectedVisibility = ref<string | undefined>("");
const queryTimer = ref(0);
const language = computed(() => {
return i18n.locale.value as AcceptedLanguage;
});
const organizations = computed(() => {
return (projectStore.organizations ?? projectStore.defaultOrganizations).map(
(organization) =>
projectMapper.map(
OrganizationDto2OrganizationForProjectManipulationDto,
organization,
),
);
});
const organizationsComplete = computed(
() => !projectStore.organizationsMeta.pagination.hasNext,
);
const visibilities = computed(() => projectStore.visibilities ?? []);
const disciplines = computed(() => projectStore.disciplines ?? []);
const disciplineLabel = computed(() => {
let locale = i18n.locale.value;
// Ensure that the value of locale is a valid locale, otherwie the property for disciplineLabel will be inaccurate
if (i18n.availableLocales.includes(locale) === false) {
locale = i18n.fallbackLocale.value.toString();
}
locale = locale.charAt(0).toUpperCase() + locale.slice(1);
return `displayName${locale}`;
});
watch(
() => props.currentProject,
() => onProjectLoaded(),
);
watch(
() => props.parentProject,
() => onParentProjectLoaded(),
);
watch(visibilities, () => {
// Used in Project Create
if (!props.currentProject) {
setDefaultVisibility();
}
});
watch(
() => projectForManipulation.value.startDate,
() => {
// Adjust the endDate to match startDate, if startDate is past endDate
const start = moment(projectForManipulation.value.startDate);
const end = moment(projectForManipulation.value.endDate);
const difference = moment.duration(start.diff(end)).asDays();
if (difference > 0) {
projectForManipulation.value.endDate =
projectForManipulation.value.startDate;
}
},
);
watch(
() => projectForManipulation.value,
() => {
emit("validation", v$.value.projectForManipulation);
},
{ deep: true },
);
watch(
() => props.isLoading,
(newVal, oldVal) => {
// If the form is no longer loading, set the organizations, but only if it was loading before
// This avoids potential recursion issues with the other watchers.
if (newVal === false && oldVal === true) {
// Responsible organization could be set outside of the component (see CreateProject.vue)
// As well as other values with the new feature of saving/restoring form data.
onProjectLoaded();
}
},
);
watch(
() => responsibleOrganization.value,
(newVal, _) => {
// Don't do anything if the form is loading to avoid recursion
if (props.isLoading) return;
// Remove any organization marked as responsible
projectForManipulation.value.organizations =
projectForManipulation.value.organizations.filter(
(org) => !org.responsible,
);
if (newVal) {
const responsibleOrganizationUri = newVal.uri;
// Remove any existing entries for this organization (responsible or not)
projectForManipulation.value.organizations =
projectForManipulation.value.organizations.filter(
(org) => org.uri !== responsibleOrganizationUri,
);
// Remove the responsible organization from additional organizations
additionalOrganizations.value = additionalOrganizations.value.filter(
(org) => org.uri !== responsibleOrganizationUri,
);
// Add the new responsible organization
projectForManipulation.value.organizations.push({
responsible: true,
uri: responsibleOrganizationUri,
displayName: newVal.displayName,
});
}
// Ensure the organizations are touched to trigger validation
v$.value.projectForManipulation.organizations.$touch();
},
);
watch(
() => additionalOrganizations.value,
(newVal, oldVal) => {
// Don't do anything if the form is loading to avoid recursion
if (props.isLoading) return;
// Find the removed additional organizations
const removedOrganizations = oldVal.filter(
(org) => !newVal.some((newOrg) => newOrg.uri === org.uri),
);
removedOrganizations.forEach((removedOrganization) => {
const removedOrganizationUri = removedOrganization.uri;
// Only remove from projectForManipulation if it's not the responsible organization
if (responsibleOrganization.value?.uri !== removedOrganizationUri) {
projectForManipulation.value.organizations =
projectForManipulation.value.organizations.filter(
(org) => org.uri !== removedOrganizationUri,
);
}
});
// Find the newly added additional organizations
const addedOrganizations = newVal.filter(
(org) => !oldVal.some((oldOrg) => oldOrg.uri === org.uri),
);
addedOrganizations.forEach((addedOrganization) => {
const addedOrganizationUri = addedOrganization.uri;
// Remove from responsible if added as additional
if (responsibleOrganization.value?.uri === addedOrganizationUri) {
responsibleOrganization.value = null;
}
// Remove any existing entries for this organization
projectForManipulation.value.organizations =
projectForManipulation.value.organizations.filter(
(org) => org.uri !== addedOrganizationUri,
);
// Add to the project organizations as non-responsible
projectForManipulation.value.organizations.push({
responsible: false,
uri: addedOrganization.uri,
displayName: addedOrganization.displayName,
});
});
// Ensure the organizations are touched to trigger validation
v$.value.projectForManipulation.organizations.$touch();
},
);
const onProjectLoaded = () => {
if (props.currentProject?.visibility?.id) {
selectedVisibility.value = props.currentProject.visibility.id;
setVisibility(selectedVisibility.value);
} else if (projectForManipulation.value?.visibility?.id) {
selectedVisibility.value = projectForManipulation.value.visibility.id;
setVisibility(selectedVisibility.value);
} else {
setDefaultVisibility();
}
setOrganizations();
// Changes have been made to the form, reset its state
v$.value.projectForManipulation.$reset();
};
const onParentProjectLoaded = () => {
const projectForCreation = projectForManipulation.value as ProjectForCreation;
if (props.parentProject && projectForCreation) {
projectForCreation.parentId = props.parentProject.id;
projectForCreation.copyOwnersFromParent = true;
}
};
/**
* Limits the number of keywords based on validation
* @param {string[]} values - array of keyword strings
* @return {number | null} - returns the current length of values if the keyword validation fails, else null
*/
const limitKeywords = (values: string[] | null | undefined): number | null => {
// Should the max number of allowed characters is exceeded, prevent the addition of further tags
if (v$.value.projectForManipulation.keywords?.$invalid) {
return values?.length ?? null;
} else {
return null;
}
};
/**
* Add a new tag to the selected keywords
* @param {string} newTag - new tag to be added to the keywords
*/
const addTag = (newTag: string) => {
projectForManipulation.value.keywords?.push(newTag);
v$.value.projectForManipulation.keywords?.$touch();
};
const retrieveMoreOrganizations = async (search: string | boolean) => {
clearTimeout(queryTimer.value);
// Add delay to API organization retrieval
queryTimer.value = window.setTimeout(async () => {
isLoadingOrganizations.value = true;
await projectStore.retrieveMoreOrganizations(search, language.value);
isLoadingOrganizations.value = false;
}, 300);
};
const copyMetadataFromParent = () => {
if (props.parentProject) {
// --- set b-placeholder while filling parent-project form ---
fillWithProjectMetadata(props.parentProject);
v$.value.projectForManipulation.$touch();
}
};
const fillWithProjectMetadata = (project: ProjectDto) => {
projectForManipulation.value.principleInvestigators =
project.principleInvestigators;
projectForManipulation.value.startDate = moment(project.startDate).format(
"YYYY-MM-DD",
);
projectForManipulation.value.endDate = moment(project.endDate).format(
"YYYY-MM-DD",
);
projectForManipulation.value.disciplines =
project.disciplines?.map((discipline) =>
projectMapper.map(
DisciplineDto2DisciplineForProjectManipulationDto,
discipline,
),
) ?? [];
projectForManipulation.value.organizations =
project.organizations?.map((organization) =>
projectMapper.map(
OrganizationDto2OrganizationForProjectManipulationDto,
organization,
),
) ?? [];
// we need to call setOrganizations() to set responsibleOrganization and additionalOrganizations based on organizations
setOrganizations();
projectForManipulation.value.keywords = project.keywords;
projectForManipulation.value.visibility = projectMapper.map(
VisibilityDto2VisibilityForProjectManipulationDto,
project.visibility,
);
projectForManipulation.value.grantId = project.grantId;
// Update v-model for Visibility
if (project.visibility) {
selectedVisibility.value = project.visibility.id;
}
};
/**
* Set visibility for the resource form
* @param {string | undefined} visibilityId - ID of the visibility option to be set
*/
const setVisibility = (visibilityId: string | undefined) => {
if (visibilityId) {
projectForManipulation.value.visibility = {
id: visibilityId,
};
v$.value.projectForManipulation.visibility.$touch();
}
};
/**
* Set default visibility option for the resource form
*/
const setDefaultVisibility = () => {
if (visibilities.value) {
const visibility = visibilities.value.find(
(entry) => entry.id === "8AB9C883-EB0D-4402-AAAD-2E4007BADCE6", // Id of "Project Members"
);
if (visibility?.id) {
selectedVisibility.value = visibility.id;
} else {
// If visibility not found or has no id, select the first visibility from the array visibilities
if (visibilities.value.length > 0) {
selectedVisibility.value = visibilities.value[0].id;
}
}
// If selectedVisibility is undefined, assign an empty string
setVisibility(selectedVisibility.value);
}
};
/**
* Sets the responsible and additional organizations for the resource form.
*
* This function checks if there are any organizations in the `projectForManipulation`
* and identifies the organization marked as responsible, setting it in `responsibleOrganization`.
* If the `responsibleOrganization` is not found in `projectForManipulation`, it attempts
* to set it from `props.currentProject`. Additionally, it populates `additionalOrganizations`
* with organizations from `props.currentProject` that are not marked as responsible.
*
* @function setOrganizations
* @returns {void}
*/
const setOrganizations = () => {
// Check if projectForManipulation.value.organizations has any entries
if (
projectForManipulation.value.organizations &&
projectForManipulation.value.organizations.length !== 0
) {
const responsibleOrgUri = projectForManipulation.value.organizations.find(
(org) => org.responsible,
)?.uri;
if (responsibleOrgUri) {
const respOrgToSet = organizations.value.find(
(org) => org.uri === responsibleOrgUri,
);
// Use Object.assign to avoid reactivity issues
responsibleOrganization.value = Object.assign({}, respOrgToSet);
}
// Populate additional organizations
additionalOrganizations.value = projectForManipulation.value.organizations
.filter((org) => !org.responsible)
.map((org) => Object.assign({}, org)); // Use Object.assign to avoid reactivity issues
}
// Check if there's a current project to use as a source for responsible organization
if (props.currentProject) {
if (!responsibleOrganization.value) {
responsibleOrganization.value =
props.currentProject.organizations.find((org) => org.responsible) ??
null;
}
// Populate additional organizations from the current project, excluding the responsible one
additionalOrganizations.value = props.currentProject.organizations
.filter((org) => !org.responsible)
.map((org) => Object.assign({}, org)); // Use Object.assign to avoid reactivity issues
}
};
onProjectLoaded();
onParentProjectLoaded();
</script>
<style></style>