Bug 38201: DRY form components

This patch continues the work to DRY form components

It creates reusable components for PatronSearch and Documents as well as moving logic into the FormElement and BaseResource components

Signed-off-by: Michaela Sieber <michaela.sieber@kit.edu>
Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Matt Blenkinsop 2024-11-22 16:03:10 +00:00 committed by Lucas Gass
parent f86f0041cb
commit b9d90163ac
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
12 changed files with 693 additions and 82 deletions

View File

@ -137,6 +137,26 @@ export default {
attr => attr.show_in_table
);
},
assignAVs(attrs) {
attrs.forEach(attr => {
if (attr.type === "select" && typeof attr.av_cat === "string") {
const av_key = attr.av_cat;
attr.options = this[av_key];
attr.requiredKey = "value";
attr.selectLabel = "description";
}
if (attr.type == "relationship" && attr.props) {
Object.keys(attr.props).forEach(key => {
if (attr.props[key].type == "av") {
attr.props[key].av = this[key];
}
});
}
if (attr.subFields?.length) {
this.assignAVs(attr.subFields);
}
});
},
},
name: "BaseResource",
props: {

View File

@ -0,0 +1,76 @@
<template>
<div class="file_information">
<span v-if="!document.file_name">
{{ $__("Select a file") }}
<input
type="file"
@change="selectFile($event, counter)"
:id="`file_${counter}`"
:name="`file_${counter}`"
/>
</span>
<span v-else>
{{ $__("Update file") }}
<input
type="file"
@change="selectFile($event, counter)"
:id="`file_${counter}`"
:name="`file_${counter}`"
/>
</span>
<ol>
<li v-show="document.file_name">
{{ $__("File name") }}:
<span>{{ document.file_name }}</span>
</li>
<li v-show="document.file_type">
{{ $__("File type") }}:
<span>{{ document.file_type }}</span>
</li>
<li v-show="document.file_name">
{{ $__("File description") }}:
<input
:id="`file_description_${counter}`"
type="text"
class="file_description"
:name="`file_description_${counter}`"
v-model="document.file_description"
:placeholder="$__('File description')"
/>
</li>
<li v-show="document.uploaded_on">
{{ $__("Uploaded on") }}:
<span>{{ format_date(document.uploaded_on) }}</span>
</li>
</ol>
</div>
</template>
<script>
export default {
props: {
counter: String,
document: Object,
},
setup() {
const format_date = $date;
return { format_date };
},
methods: {
selectFile(e, i) {
let files = e.target.files;
if (!files) return;
let file = files[0];
const reader = new FileReader();
reader.onload = e => this.loadFile(file.name, e.target.result, i);
reader.readAsBinaryString(file);
},
loadFile(filename, content, i) {
this.document.file_name = filename;
this.document.file_content = btoa(content);
},
},
};
</script>
<style></style>

View File

@ -15,7 +15,15 @@
>
</legend>
<ol>
<li>
<li v-for="(attr, index) in subFields" v-bind:key="index">
<FormElement
:resource="agreement_license"
:attr="attr"
:index="index"
/>
</li>
<!-- <li>
<label :for="`license_id_${counter}`" class="required"
>{{ $__("License") }}:</label
>
@ -83,7 +91,7 @@
v-model="agreement_license.uri"
:placeholder="$__('URI')"
/>
</li>
</li> -->
</ol>
</fieldset>
<a v-if="license_count > 0" class="btn btn-default" @click="addLicense"
@ -95,7 +103,8 @@
<script>
import { APIClient } from "../../fetch/api-client.js";
import InfiniteScrollSelect from "../InfiniteScrollSelect.vue";
import FormElement from "../FormElement.vue";
// import InfiniteScrollSelect from "../InfiniteScrollSelect.vue"
export default {
name: "AgreementLicenses",
@ -108,6 +117,7 @@ export default {
av_agreement_license_statuses: Array,
av_agreement_license_location: Array,
agreement_licenses: Array,
subFields: Array,
},
beforeCreate() {
const client = APIClient.erm;
@ -135,7 +145,8 @@ export default {
},
},
components: {
InfiniteScrollSelect,
// InfiniteScrollSelect,
FormElement,
},
};
</script>

View File

@ -15,7 +15,15 @@
>
</legend>
<ol>
<li>
<li v-for="(attr, index) in subFields" v-bind:key="index">
<FormElement
:resource="period"
:attr="attr"
:index="counter"
/>
</li>
<!-- <li>
<label :for="`started_on_${counter}`" class="required"
>{{ $__("Start date") }}:
</label>
@ -57,7 +65,7 @@
:name="`notes_${counter}`"
v-model="period.notes"
/>
</li>
</li> -->
</ol>
</fieldset>
<a class="btn btn-default" @click="addPeriod"
@ -67,7 +75,7 @@
</template>
<script>
import flatPickr from "vue-flatpickr-component";
import FormElement from "../FormElement.vue";
export default {
name: "AgreementPeriods",
data() {
@ -77,6 +85,7 @@ export default {
},
props: {
periods: Array,
subFields: Array,
},
methods: {
addPeriod() {
@ -92,7 +101,7 @@ export default {
},
},
components: {
flatPickr,
FormElement,
},
};
</script>

View File

@ -15,7 +15,16 @@
>
</legend>
<ol>
<li>
<li v-for="(attr, index) in subFields" v-bind:key="index">
<FormElement
:resource="relationship"
:attr="attr"
:index="counter"
:options="agreements"
/>
</li>
<!-- <li>
<label
:for="`related_agreement_id_${counter}`"
class="required"
@ -72,7 +81,7 @@
v-model="relationship.notes"
:placeholder="$__('Notes')"
/>
</li>
</li> -->
</ol>
</fieldset>
<a
@ -90,8 +99,10 @@
<script>
import { APIClient } from "../../fetch/api-client.js";
import FormElement from "../FormElement.vue";
export default {
components: { FormElement },
data() {
return {
agreements: [],
@ -125,6 +136,7 @@ export default {
agreement_id: Number,
av_agreement_relationships: Array,
relationships: Array,
subFields: Array,
},
name: "AgreementRelationships",
};

View File

@ -55,9 +55,15 @@ export default {
{
name: "vendor_id",
type: "component",
component: "FormSelectVendors",
label: __("Vendor"),
show_in_table: true,
componentPath: "./FormSelectVendors.vue",
props: {
id: {
type: "string",
value: "license_id_",
indexRequired: true,
},
},
},
{
name: "description",
@ -70,14 +76,14 @@ export default {
{
name: "status",
required: true,
type: "av",
type: "select",
label: __("Status"),
av_cat: "av_agreement_statuses",
show_in_table: true,
},
{
name: "closure_reason",
type: "av",
type: "select",
label: __("Closure reason"),
av_cat: "av_agreement_closure_reasons",
show_in_table: true,
@ -90,7 +96,7 @@ export default {
},
{
name: "renewal_priority",
type: "av",
type: "select",
label: __("Renewal priority"),
av_cat: "av_agreement_renewal_priorities",
show_in_table: true,
@ -102,40 +108,306 @@ export default {
text_area_cols: 50,
label: __("License info"),
},
// {
// name: "periods",
// type: "relationship",
// },
// {
// name: "user_roles",
// type: "relationship",
// },
// {
// name: "licenses",
// type: "relationship",
// },
// {
// name: "agreements",
// type: "relationship",
// },
// {
// name: "documents",
// type: "relationship",
// },
{
name: "periods",
type: "relationship",
label: __("Periods"),
componentPath: "./ERM/AgreementPeriods.vue",
props: {
periods: {
type: "resourceProperty",
resourceProperty: "periods",
},
},
subFields: [
{
name: "started_on",
type: "component",
label: __("Start date"),
componentPath: "./FlatPickrWrapper.vue",
required: true,
props: {
id: {
type: "string",
value: "started_on_",
indexRequired: true,
},
required: {
type: "boolean",
value: true,
},
date_to: {
type: "string",
value: "ended_on_",
indexRequired: true,
},
},
},
{
name: "ended_on",
type: "component",
label: __("End date"),
componentPath: "./FlatPickrWrapper.vue",
required: false,
props: {
id: {
type: "string",
value: "ended_on_",
indexRequired: true,
},
},
},
{
name: "cancellation_deadline",
type: "component",
label: __("Cancellation deadline"),
componentPath: "./FlatPickrWrapper.vue",
required: false,
props: {
id: {
type: "string",
value: "cancellation_deadline_",
indexRequired: true,
},
},
},
{
name: "notes",
required: false,
type: "text",
label: __("Notes"),
},
],
},
{
name: "user_roles",
type: "relationship",
label: __("Users"),
componentPath: "./ERM/UserRoles.vue",
props: {
user_roles: {
type: "resourceProperty",
resourceProperty: "user_roles",
},
av_user_roles: {
type: "av",
av: null,
},
user_type: {
type: "string",
value: __("Agreement user %s"),
},
},
subFields: [
{
name: "user_id",
type: "component",
label: __("User"),
componentPath: "./PatronSearch.vue",
required: true,
props: {
name: {
type: "string",
value: "user_id",
},
required: {
type: "boolean",
value: true,
},
resource: {
type: "resource",
value: null,
},
counter: {
type: "string",
value: "",
indexRequired: true,
},
label: {
type: "string",
value: __("User"),
},
},
},
{
name: "role",
type: "select",
label: __("Role"),
av_cat: "av_user_roles",
required: true,
},
],
},
{
name: "licenses",
type: "relationship",
label: __("Licenses"),
componentPath: "./ERM/AgreementLicenses.vue",
props: {
agreement_licenses: {
type: "resourceProperty",
resourceProperty: "agreement_licenses",
},
av_agreement_license_statuses: {
type: "av",
av: null,
},
av_agreement_license_location: {
type: "av",
av: null,
},
},
subFields: [
{
name: "license_id",
type: "component",
label: __("License"),
componentPath: "./InfiniteScrollSelect.vue",
required: true,
props: {
id: {
type: "string",
value: "license_id_",
indexRequired: true,
},
selectedData: {
type: "resourceProperty",
resourceProperty: "license",
},
dataType: {
type: "string",
value: "licenses",
},
dataIdentifier: {
type: "string",
value: "license_id",
},
label: {
type: "string",
value: "name",
},
required: {
type: "boolean",
value: true,
},
},
},
{
name: "status",
type: "select",
label: __("Status"),
av_cat: "av_agreement_license_statuses",
},
{
name: "physical_location",
type: "select",
label: __("Physical location"),
av_cat: "av_agreement_license_location",
},
{
name: "notes",
required: false,
type: "text",
label: __("Notes"),
},
{
name: "uri",
required: false,
type: "text",
label: __("URI"),
},
],
},
{
name: "relationships",
type: "relationship",
componentPath: "./ERM/AgreementRelationships.vue",
props: {
agreement_id: {
type: "resourceProperty",
resourceProperty: "agreement_id",
},
av_agreement_relationships: {
type: "av",
av: null,
},
relationships: {
type: "resourceProperty",
resourceProperty: "agreement_relationships",
},
},
subFields: [
{
name: "related_agreement_id",
type: "select",
label: __("Related agreement"),
requiredKey: "agreement_id",
selectLabel: "name",
},
{
name: "relationship",
type: "select",
label: __("Relationship"),
av_cat: "av_agreement_relationships",
},
{
name: "notes",
required: false,
type: "text",
label: __("Notes"),
},
],
},
{
name: "documents",
type: "relationship",
componentPath: "./ERM/Documents.vue",
props: {
documents: {
type: "resourceProperty",
resourceProperty: "documents",
},
},
subFields: [
{
name: "document",
type: "component",
componentPath: "./DocumentSelect.vue",
label: __("File"),
props: {
counter: {
type: "string",
value: "",
indexRequired: true,
},
document: {
type: "resource",
value: null,
},
},
},
{
name: "uri",
required: false,
type: "text",
label: __("URI"),
},
{
name: "notes",
required: false,
type: "text",
label: __("Notes"),
},
],
},
],
};
},
created() {
//IMPROVEME: We need this for now to assign the correct av array from setup to the attr options in data
this.resource_attrs.forEach(attr => {
if (typeof attr.av_cat === "string") {
const av_key = attr.av_cat;
attr.options = this[av_key];
}
});
this.assignAVs(this.resource_attrs);
this.getResourceTableColumns();
},
methods: {},
name: "AgreementResource",
};
</script>

View File

@ -10,10 +10,16 @@
<fieldset class="rows">
<ol>
<li
v-for="(attr, index) in resource_attrs"
v-for="(attr, index) in resource_attrs.filter(
attr => attr.type !== 'relationship'
)"
v-bind:key="index"
>
<FormElement :resource="agreement" :attr="attr" />
<FormElement
:resource="agreement"
:attr="attr"
:index="index"
/>
</li>
</ol>
</fieldset>
@ -23,13 +29,21 @@
@additional-fields-changed="additionalFieldsChanged"
/>
<!-- These relationships need DRY -->
<AgreementPeriods :periods="agreement.periods" />
<template
v-for="(attr, index) in resource_attrs.filter(
attr => attr.type === 'relationship'
)"
v-bind:key="'rel-' + index"
>
<FormElement :resource="agreement" :attr="attr" />
</template>
<!-- <AgreementPeriods :periods="agreement.periods" />
<UserRoles
:user_type="$__('Agreement user %s')"
:user_roles="agreement.user_roles"
:av_user_roles="authorisedValues['av_user_roles']"
/>
<AgreementLicenses
:av_user_roles="av_user_roles"
/> -->
<!-- <AgreementLicenses
:agreement_licenses="agreement.agreement_licenses"
:av_agreement_license_statuses="
authorisedValues['av_agreement_license_statuses']
@ -37,15 +51,15 @@
:av_agreement_license_location="
authorisedValues['av_agreement_license_location']
"
/>
<AgreementRelationships
/> -->
<!-- <AgreementRelationships
:agreement_id="agreement.agreement_id"
:relationships="agreement.agreement_relationships"
:av_agreement_relationships="
authorisedValues['av_agreement_relationships']
"
/>
<Documents :documents="agreement.documents" />
<Documents :documents="agreement.documents" /> -->
<!-- These relationships need DRY -->
<fieldset class="action">
<ButtonSubmit />

View File

@ -15,7 +15,15 @@
>
</legend>
<ol>
<li>
<li v-for="(attr, index) in subFields" v-bind:key="index">
<FormElement
:resource="document"
:attr="attr"
:index="counter"
/>
</li>
<!-- <li>
<label>{{ $__("File") }}:</label>
<div class="file_information">
<span v-if="!document.file_name">
@ -96,7 +104,7 @@
v-model="document.notes"
:placeholder="$__('Notes')"
/>
</li>
</li> -->
</ol>
</fieldset>
<a class="btn btn-default" @click="addDocument"
@ -106,7 +114,9 @@
</template>
<script>
import FormElement from "../FormElement.vue";
export default {
components: { FormElement },
setup() {
const format_date = $date;
return { format_date };
@ -142,6 +152,7 @@ export default {
name: "Documents",
props: {
documents: Array,
subFields: Array,
},
};
</script>

View File

@ -14,7 +14,15 @@
>
</legend>
<ol>
<li>
<li v-for="(attr, index) in subFields" v-bind:key="index">
<FormElement
:resource="user_role"
:attr="attr"
:index="counter"
/>
</li>
<!-- <li>
<label :for="`user_id_${counter}`" class="required"
>{{ $__("User") }}:</label
>
@ -30,8 +38,8 @@
><i class="fa fa-plus"></i> {{ $__("Select user") }}</a
>
<span class="required">{{ $__("Required") }}</span>
</li>
<li>
</li> -->
<!-- <li>
<label :for="`user_role_${counter}`" class="required"
>{{ $__("Role") }}:</label
>
@ -52,7 +60,7 @@
</template>
</v-select>
<span class="required">{{ $__("Required") }}</span>
</li>
</li> -->
</ol>
</fieldset>
<input
@ -68,13 +76,16 @@
<script>
import { APIClient } from "../../fetch/api-client.js";
import FormElement from "../FormElement.vue";
export default {
components: { FormElement },
name: "UserRoles",
props: {
user_type: String,
av_user_roles: Array,
user_roles: Array,
subFields: Array,
},
beforeCreate() {
this.user_roles.forEach(u => {

View File

@ -0,0 +1,42 @@
<template>
<flatPickr
:id="id"
v-model="model"
:required="required"
:config="fp_config"
:data-date_to="date_to"
/>
</template>
<script>
import flatPickr from "vue-flatpickr-component";
export default {
props: {
id: String,
modelValue: String,
required: Boolean,
date_to: String,
},
emits: ["update:modelValue"],
data() {
return {
fp_config: flatpickr_defaults,
};
},
computed: {
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
},
components: { flatPickr },
name: "FlatPickrWrapper",
};
</script>
<style></style>

View File

@ -1,10 +1,12 @@
<template>
<div v-if="attr.type == 'text'">
<label :for="attr.name" :class="{ required: attr.required }"
<label
:for="`${attr.name}${index}`"
:class="{ required: attr.required }"
>{{ attr.label }}:</label
>
<input
:id="attr.name"
:id="`${attr.name}${index}`"
v-model="resource[attr.name]"
:placeholder="attr.label"
:required="attr.required ? true : false"
@ -12,11 +14,13 @@
<span v-if="attr.required" class="required">{{ $__("Required") }}</span>
</div>
<div v-else-if="attr.type == 'textarea'">
<label :for="attr.name" :class="{ required: attr.required }"
<label
:for="`${attr.name}${index}`"
:class="{ required: attr.required }"
>{{ attr.label }}:</label
>
<textarea
:id="attr.name"
:id="`${attr.name}${index}`"
v-model="resource[attr.name]"
:rows="attr.text_area_rows"
:cols="attr.text_area_col"
@ -26,8 +30,8 @@
<span v-if="attr.required" class="required">{{ $__("Required") }}</span>
</div>
<div v-else-if="attr.type == 'boolean'">
<label :for="attr.name">{{ attr.label }}:</label>
<label class="radio" :for="attr.name + '_yes'"
<label :for="`${attr.name}${index}`">{{ attr.label }}:</label>
<label class="radio" :for="`${attr.name}${index}` + '_yes'"
>{{ $__("Yes") }}:
<input
type="radio"
@ -48,16 +52,18 @@
/>
</label>
</div>
<div v-else-if="attr.type == 'av'">
<label :for="attr.name" :class="{ required: attr.required }"
<div v-else-if="attr.type == 'select'">
<label
:for="`${attr.name}${index}`"
:class="{ required: attr.required }"
>{{ attr.label }}:</label
>
<v-select
:id="attr.name"
:id="`${attr.name}${index}`"
v-model="resource[attr.name]"
label="description"
:reduce="av => av.value"
:options="attr.options"
:label="attr.selectLabel"
:reduce="av => av[attr.requiredKey]"
:options="selectOptions"
:required="!resource[attr.name] && attr.required"
:disabled="attr.disabled"
>
@ -72,26 +78,95 @@
</v-select>
<span v-if="attr.required" class="required">{{ $__("Required") }}</span>
</div>
<div
v-else-if="
attr.type == 'component' && attr.component == 'FormSelectVendors'
"
>
<label :for="attr.name">{{ attr.label }}:</label>
<FormSelectVendors :id="attr.name" v-model="resource[attr.name]">
</FormSelectVendors>
<div v-else-if="attr.type == 'component'">
<label :for="attr.name" :class="{ required: attr.required }"
>{{ attr.label }}:</label
>
<component
v-if="isVModelRequired(attr.componentPath)"
:is="requiredComponent"
v-bind="requiredProps()"
v-model="resource[attr.name]"
></component>
<component
v-else
:is="requiredComponent"
v-bind="requiredProps()"
></component>
<span v-if="attr.required" class="required">{{ $__("Required") }}</span>
</div>
<template v-else-if="attr.type == 'relationship'">
<component :is="requiredComponent" v-bind="requiredProps()"></component>
</template>
</template>
<script>
import FormSelectVendors from "./FormSelectVendors.vue";
import { defineAsyncComponent } from "vue";
export default {
props: {
resource: null,
attr: null,
resource: Object | null,
attr: Object | null,
index: Number | null,
options: Array,
},
components: {
FormSelectVendors,
computed: {
requiredComponent() {
return defineAsyncComponent(
() => import(`${this.attr.componentPath}`)
);
},
selectOptions() {
if (this.attr.options) {
return this.attr.options;
}
return this.options;
},
},
methods: {
requiredProps() {
if (!this.attr.props) {
return {};
}
const props = Object.keys(this.attr.props).reduce((acc, key) => {
// This might be better in a switch statement
const prop = this.attr.props[key];
if (prop.type === "resource") {
acc[key] = this.resource;
}
if (prop.type === "resourceProperty") {
acc[key] = this.resource[prop.resourceProperty];
}
if (prop.type === "av") {
acc[key] = prop.av;
}
if (prop.type === "string") {
if (prop.indexRequired && this.index > -1) {
acc[key] = `${prop.value}${this.index}`;
} else {
acc[key] = prop.value;
}
}
if (prop.type === "boolean") {
acc[key] = prop.value;
}
return acc;
}, {});
if (this.attr.subFields?.length) {
props.subFields = this.attr.subFields;
}
return props;
},
isVModelRequired(componentPath) {
let vModelRequired = true;
const componentsNotRequiringVModel = ["PatronSearch", "Documents"];
componentsNotRequiringVModel.forEach(component => {
if (componentPath.includes(component)) {
vModelRequired = false;
}
});
return vModelRequired;
},
},
name: "FormElement",
};

View File

@ -0,0 +1,58 @@
<template>
<span class="user">
{{ resource.patron_str }}
</span>
&nbsp;
<a
href="#patron_search_modal"
@click="selectUser(counter)"
class="btn btn-default"
data-bs-toggle="modal"
><i class="fa fa-plus"></i> {{ $__("Select user") }}</a
>
</template>
<script>
import { APIClient } from "../fetch/api-client.js";
export default {
props: {
name: String,
resource: Object,
counter: String,
label: String,
required: Boolean,
},
beforeCreate() {
this.resource.patron_str = $patron_to_html(this.resource.patron);
},
methods: {
selectUser(counter) {
// This is a bit dirty, the "select user" window should be rewritten and be a Vue component
// but that's not for now...
$(document).on(
"hidden.bs.modal",
"#patron_search_modal",
this.newUserSelected
);
// this.selected_user_counter = counter
},
newUserSelected(e) {
// let c = this.selected_user_counter
let selected_patron_id =
document.getElementById("selected_patron_id").value;
let patron;
const client = APIClient.patron;
// FIXME We are missing a "loading..."
client.patrons.get(selected_patron_id).then(p => {
patron = p;
this.resource.patron = patron;
this.resource.patron_str = $patron_to_html(patron);
this.resource.user_id = patron.patron_id;
});
},
},
};
</script>
<style></style>