Bugzilla – Attachment 193626 Details for
Bug 39224
Migrate SAML/Shibboleth configuration into Identity Providers
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 39224: Vue framework component enhancements
0dd92d7.patch (text/plain), 24.53 KB, created by
Martin Renvoize (ashimema)
on 2026-02-23 11:17:07 UTC
(
hide
)
Description:
Bug 39224: Vue framework component enhancements
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2026-02-23 11:17:07 UTC
Size:
24.53 KB
patch
obsolete
>From 0dd92d73e9c84b9f11a4a823b2001f3dfcbb194f Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Sun, 22 Feb 2026 20:19:33 +0000 >Subject: [PATCH] Bug 39224: Vue framework component enhancements > >Enhances shared Vue components to support the identity providers >admin interface and improve reusability: > >- AccordionWrapper: Adds dynamic slot support >- FormElement: Adds textarea and select type support >- FormSection: New component for grouping form fields >- KohaTable: Adds column visibility and action customisation >- RelationshipWidget: Adds slot for custom content >- ResourceFormSave: Adds support for nested resources and > custom save handlers >- ResourceShow: Adds support for related resource display >- ShowElement: Adds boolean and list display support >- base-resource composable: Adds relationship management and > improved error handling > >Test plan: >1. Verify existing Vue SPAs (e.g. ERM) still function correctly >2. Components should be backwards compatible > >Sponsored-by: ByWater Solutions >--- > .../js/vue/components/AccordionWrapper.vue | 20 ++- > .../prog/js/vue/components/FormElement.vue | 11 +- > .../prog/js/vue/components/FormSection.vue | 147 ++++++++++++++++++ > .../prog/js/vue/components/KohaTable.vue | 9 +- > .../js/vue/components/RelationshipWidget.vue | 2 + > .../js/vue/components/ResourceFormSave.vue | 51 +++++- > .../prog/js/vue/components/ResourceShow.vue | 90 +++++++++-- > .../prog/js/vue/components/ShowElement.vue | 10 ++ > .../prog/js/vue/composables/base-resource.js | 35 ++++- > 9 files changed, 341 insertions(+), 34 deletions(-) > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/FormSection.vue > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/AccordionWrapper.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/AccordionWrapper.vue >index 64c35f77cb5..ddee8df9449 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/AccordionWrapper.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/AccordionWrapper.vue >@@ -3,6 +3,7 @@ > v-for="(group, counter) in accordionList" > v-bind:key="counter" > class="accordion" >+ :class="{ 'group-placeholder-accordion': group.placeholder }" > > > <fieldset class="accordion-item"> > <legend >@@ -22,7 +23,12 @@ > :aria-labelledby="`heading-${counter}`" > data-bs-parent="#formAccordion" > > >- <fieldset class="accordion-body rows"> >+ <div v-if="group.placeholder" class="placeholder-content"> >+ <p v-if="group.placeholder.description" class="mb-0"> >+ {{ group.placeholder.description }} >+ </p> >+ </div> >+ <fieldset v-else class="accordion-body rows"> > <slot name="accordionContent" :accordionGroup="group" /> > </fieldset> > </div> >@@ -41,4 +47,14 @@ export default { > }; > </script> > >-<style></style> >+<style> >+.group-placeholder-accordion > fieldset { >+ background-color: var(--bs-info-bg-subtle); >+ border-radius: 4px; >+} >+.group-placeholder-accordion .placeholder-content { >+ color: #6c757d; >+ font-style: italic; >+ padding: 0.75rem 1rem; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormElement.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormElement.vue >index 8379c7eea33..a89c4d3553b 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormElement.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormElement.vue >@@ -81,8 +81,8 @@ > >{{ $__("Yes") }}: > <InputRadio > type="radio" >- :name="attr.name" >- :id="attr.name + '_yes'" >+ :name="getElementId" >+ :id="getElementId + '_yes'" > :value="true" > :checked="resource[attr.name] == true" > v-model="resource[attr.name]" >@@ -92,8 +92,8 @@ > >{{ $__("No") }}: > <InputRadio > type="radio" >- :name="attr.name" >- :id="attr.name + '_no'" >+ :name="getElementId" >+ :id="getElementId + '_no'" > :checked="resource[attr.name] == false" > :value="false" > v-model="resource[attr.name]" >@@ -110,6 +110,9 @@ > :required="!resource[attr.name] && attr.required" > :disabled="disabled" > :multiple="attr.allowMultipleChoices" >+ :taggable="attr.taggable || false" >+ :create-option="attr.createOption" >+ :placeholder="attr.placeholder" > @option:selected="attr.onSelected && attr.onSelected(resource)" > > > <template v-if="attr.required" #search="{ attributes, events }"> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormSection.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormSection.vue >new file mode 100644 >index 00000000000..d49dce3fdeb >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/FormSection.vue >@@ -0,0 +1,147 @@ >+<template> >+ <div class="page-section"> >+ <div class="d-flex justify-content-between align-items-center mb-2"> >+ <h2> >+ <i v-if="group.icon" :class="group.icon"></i> >+ {{ group.name }} >+ </h2> >+ <button >+ v-if="!editing && !disabled" >+ type="button" >+ class="btn btn-default" >+ @click="startEdit" >+ > >+ <i class="fa fa-pencil"></i> >+ {{ $__("Edit") }} >+ </button> >+ <span v-if="disabled" class="text-muted">{{ >+ $__("Save basic settings first") >+ }}</span> >+ </div> >+ >+ <!-- VIEW MODE --> >+ <template v-if="!editing || disabled"> >+ <fieldset class="rows"> >+ <ol> >+ <li v-for="attr in group.fields" :key="attr.name"> >+ <ShowElement >+ :resource="resource" >+ :attr="attr" >+ :instancedResource="instancedResource" >+ /> >+ </li> >+ </ol> >+ </fieldset> >+ </template> >+ >+ <!-- EDIT MODE --> >+ <template v-else> >+ <fieldset class="rows"> >+ <ol> >+ <li v-for="(attr, index) in group.fields" :key="index"> >+ <FormElement >+ :resource="localResource" >+ :attr="attr" >+ :index="index" >+ /> >+ </li> >+ </ol> >+ </fieldset> >+ >+ <fieldset class="action"> >+ <button >+ type="button" >+ class="btn btn-primary" >+ @click="saveSection" >+ > >+ <i class="fa fa-save"></i> >+ {{ $__("Save") }} >+ </button> >+ <button type="button" class="cancel" @click="cancelEdit"> >+ {{ $__("Cancel") }} >+ </button> >+ </fieldset> >+ </template> >+ </div> >+</template> >+ >+<script> >+import { ref, watch, toRaw } from "vue"; >+import { $__ } from "@koha-vue/i18n"; >+import FormElement from "@koha-vue/components/FormElement.vue"; >+import ShowElement from "@koha-vue/components/ShowElement.vue"; // New Import >+ >+export default { >+ name: "FormSection", >+ components: { >+ FormElement, >+ ShowElement, // New Component >+ }, >+ props: { >+ group: { >+ type: Object, >+ required: true, >+ // Example structure: { name: 'Section Title', icon: 'fa fa-info', fields: [...] } >+ }, >+ resource: { >+ type: Object, >+ required: true, >+ }, >+ instancedResource: { >+ // New Prop >+ type: Object, >+ required: true, >+ }, >+ startEditing: { >+ type: Boolean, >+ default: false, >+ }, >+ disabled: { >+ type: Boolean, >+ default: false, >+ }, >+ }, >+ emits: ["save", "cancel"], >+ setup(props, { emit }) { >+ const editing = ref(props.startEditing); >+ const localResource = ref({}); >+ >+ // Initialize localResource when the component is created or resource prop changes >+ watch( >+ () => props.resource, >+ newResource => { >+ localResource.value = { ...toRaw(newResource) }; >+ }, >+ { immediate: true } >+ ); >+ >+ const startEdit = () => { >+ localResource.value = { ...toRaw(props.resource) }; // Copy original for cancellation >+ editing.value = true; >+ }; >+ >+ const cancelEdit = () => { >+ editing.value = false; >+ // localResource is automatically reverted to original via watch effect on props.resource >+ }; >+ >+ const saveSection = () => { >+ emit("save", props.group.name, localResource.value); >+ editing.value = false; >+ }; >+ >+ return { >+ editing, >+ localResource, >+ startEdit, >+ cancelEdit, >+ saveSection, >+ $__, >+ }; >+ }, >+}; >+</script> >+ >+<style scoped> >+/* Add any scoped styles here if necessary, similar to ProviderWorkspace.vue */ >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaTable.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaTable.vue >index d02e438baf4..382398d9032 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaTable.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaTable.vue >@@ -247,7 +247,7 @@ export default { > }); > > if (Object.keys(props.actions).length) { >- dt.on("draw", () => { >+ const bindActionHandlers = () => { > const dataSet = dt.rows().data(); > Object.entries(props.actions).forEach( > ([col_id, actions]) => { >@@ -271,7 +271,12 @@ export default { > }); > } > ); >- }); >+ }; >+ dt.on("draw", bindActionHandlers); >+ // For tables using static `data` prop, the initial draw fires >+ // during child mount (before this onMounted runs), so we must >+ // bind handlers immediately for the rows already in the DOM. >+ bindActionHandlers(); > } > }); > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/RelationshipWidget.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/RelationshipWidget.vue >index 3ea944e0399..b5cfa588c52 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/RelationshipWidget.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/RelationshipWidget.vue >@@ -56,11 +56,13 @@ export default { > provide("resourceRelationships", props.resourceRelationships); > > const addResourceRelationship = () => { >+ if (!props.resourceRelationships) return; > props.resourceRelationships.push({ > ...props.newRelationshipDefaultAttrs, > }); > }; > const deleteResourceRelationship = counter => { >+ if (!props.resourceRelationships) return; > props.resourceRelationships.splice(counter, 1); > }; > const getSelectOptions = filters => { >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceFormSave.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceFormSave.vue >index e1fc2215c89..3291988dd2a 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceFormSave.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceFormSave.vue >@@ -67,7 +67,11 @@ > </ol> > </template> > </AccordionWrapper> >- <div v-else> >+ <div >+ v-else-if=" >+ instancedResource.formGroupsDisplayMode !== 'sections' >+ " >+ > > <fieldset > v-for="( > group, counter >@@ -90,6 +94,21 @@ > </ol> > </fieldset> > </div> >+ <div v-else> >+ <FormSection >+ v-for="( >+ group, counter >+ ) in instancedResource.getFieldGroupings('Form')" >+ v-bind:key="counter" >+ :group="group" >+ :resource="resourceToSave" >+ :instancedResource="instancedResource" >+ @save=" >+ (groupName, updatedResource) => >+ handleSectionSave(groupName, updatedResource) >+ " >+ /> >+ </div> > <fieldset > class="action" > v-if=" >@@ -122,6 +141,7 @@ import ButtonSubmit from "./ButtonSubmit.vue"; > import DropdownButtons from "./DropdownButtons.vue"; > import TabsWrapper from "./TabsWrapper.vue"; > import AccordionWrapper from "./AccordionWrapper.vue"; >+import FormSection from "./FormSection.vue"; // New Import > import { $__ } from "@koha-vue/i18n"; > > export default { >@@ -169,7 +189,7 @@ export default { > } > }); > >- const saveAndNavigate = ($event, resourceToSave) => { >+ const saveAndNavigate = async ($event, partialResource = null) => { > const { > components, > navigationOnFormSave, >@@ -177,6 +197,12 @@ export default { > router, > idAttr, > } = props.instancedResource; >+ >+ // Merge partialResource if provided (for section saves) >+ if (partialResource) { >+ Object.assign(resourceToSave.value, partialResource); >+ } >+ > // Default to show > const navigationAction = > saveOptionSelected.value || >@@ -185,18 +211,29 @@ export default { > const idParamRequired = > navigationAction === components.show || > navigationAction === components.edit; >- onFormSave($event, resourceToSave).then(resource => { >- if (resource) { >+ >+ try { >+ const savedResource = await onFormSave( >+ $event, >+ resourceToSave.value >+ ); >+ if (savedResource) { > router.push({ > name: navigationAction, > ...(idParamRequired && { > params: { >- [idAttr]: resource[idAttr], >+ [idAttr]: savedResource[idAttr], > }, > }), > }); > } >- }); >+ } catch (error) { >+ // onFormSave or router push might throw, handled by instancedResource usually >+ } >+ }; >+ >+ const handleSectionSave = (groupName, updatedResource) => { >+ Object.assign(resourceToSave.value, updatedResource); > }; > > const saveOptionSelected = ref( >@@ -270,6 +307,7 @@ export default { > resourceToSave, > resourceForm, > saveAndNavigate, >+ handleSectionSave, > editMode, > saveDropdownButtonActions, > }; >@@ -282,6 +320,7 @@ export default { > FormElement, > TabsWrapper, > AccordionWrapper, >+ FormSection, // New Component > DropdownButtons, > }, > name: "ResourceFormSave", >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceShow.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceShow.vue >index 9d3dbb654fb..94a7168deaf 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceShow.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ResourceShow.vue >@@ -6,7 +6,8 @@ > :resource="resource" > :componentPropData="{ ...$props, ...$data }" > /> >- <h2> >+ <h2 v-if="isNewResource">{{ instancedResource.i18n.newLabel }}</h2> >+ <h2 v-else> > {{ > instancedResource.i18n.displayName + > " #" + >@@ -78,6 +79,25 @@ > </fieldset> > </template> > </SplitScreenWrapper> >+ <div v-else-if="displayMode == 'sections'"> >+ <FormSection >+ v-for="(group, counter) in fieldList" >+ v-bind:key="counter" >+ :group="group" >+ :resource="resource" >+ :instancedResource="instancedResource" >+ :startEditing="isNewResource" >+ :disabled=" >+ group.requiresId && >+ isNewResource && >+ !resource[instancedResource.idAttr] >+ " >+ @save=" >+ (groupName, updatedResource) => >+ handleSectionSave(groupName, updatedResource) >+ " >+ /> >+ </div> > <div v-else> > <fieldset > class="rows" >@@ -113,6 +133,7 @@ > <script> > import Toolbar from "./Toolbar.vue"; > import ShowElement from "./ShowElement.vue"; >+import FormSection from "./FormSection.vue"; > import { computed, onBeforeMount, ref } from "vue"; > import TabsWrapper from "./TabsWrapper.vue"; > import AccordionWrapper from "./AccordionWrapper.vue"; >@@ -125,25 +146,63 @@ export default { > const resource = ref(null); > const additionalProps = ref({}); > >- onBeforeMount(() => { >- props.instancedResource.getResource( >- props.instancedResource.route.params[ >+ const isNewResource = computed( >+ () => >+ !props.instancedResource.route.params[ > props.instancedResource.idAttr >- ], >- { >- resource, >- initialized, >- instancedResource: props.instancedResource, >- additionalProps, >- }, >- "show" >- ); >+ ] >+ ); >+ >+ onBeforeMount(() => { >+ if (isNewResource.value) { >+ resource.value = {}; >+ initialized.value = true; >+ } else { >+ props.instancedResource.getResource( >+ props.instancedResource.route.params[ >+ props.instancedResource.idAttr >+ ], >+ { >+ resource, >+ initialized, >+ instancedResource: props.instancedResource, >+ additionalProps, >+ }, >+ "show" >+ ); >+ } > }); > >+ const handleSectionSave = async (groupName, updatedResource) => { >+ try { >+ const result = await props.instancedResource.onShowSectionSave( >+ groupName, >+ updatedResource, >+ resource.value, >+ isNewResource.value >+ ); >+ if (result) { >+ Object.assign(resource.value, result); >+ const newId = result[props.instancedResource.idAttr]; >+ if (isNewResource.value && newId) { >+ props.instancedResource.router.push({ >+ name: props.instancedResource.components.show, >+ params: { >+ [props.instancedResource.idAttr]: newId, >+ }, >+ }); >+ } >+ } >+ } catch (error) { >+ // errors are surfaced by the httpClient / setError >+ } >+ }; >+ > const fieldList = computed(() => { > const fieldGroupings = props.instancedResource.getFieldGroupings( > "Show", >- resource.value >+ // Pass null for new resources so the hasDataToDisplay filter is skipped >+ isNewResource.value ? null : resource.value > ); > const fieldsToAppend = props.instancedResource > .appendToShow({ >@@ -185,6 +244,8 @@ export default { > additionalProps, > fieldList, > displayMode, >+ isNewResource, >+ handleSectionSave, > }; > }, > props: { >@@ -193,6 +254,7 @@ export default { > components: { > Toolbar, > ShowElement, >+ FormSection, > TabsWrapper, > AccordionWrapper, > SplitScreenWrapper, >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ShowElement.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ShowElement.vue >index 34e50d36bf9..e3128d6f1f3 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/ShowElement.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/ShowElement.vue >@@ -134,6 +134,16 @@ > ></component> > </template> > </template> >+ <template >+ v-else-if="attr.type === 'relationshipWidget' && attr.componentProps" >+ > >+ <component >+ :is="requiredComponent" >+ :title="attr.group ? null : attr.label" >+ :name="attr.name" >+ v-bind="getComponentProps(true)" >+ ></component> >+ </template> > <template > v-else-if=" > attr.type == 'additional_fields' && >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/composables/base-resource.js b/koha-tmpl/intranet-tmpl/prog/js/vue/composables/base-resource.js >index 38dd558666b..7290b0f9857 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/composables/base-resource.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/composables/base-resource.js >@@ -415,7 +415,7 @@ export function useBaseResource(resourceConfig) { > const attributesToConsider = resourceConfig.resourceAttrs.reduce( > (acc, ra) => { > if (ra.type === "relationshipWidget") { >- ra.relationshipFields.forEach(relationshipField => { >+ ra.relationshipFields?.forEach(relationshipField => { > relationshipField.relationshipName = ra.name; > }); > } >@@ -484,6 +484,18 @@ export function useBaseResource(resourceConfig) { > fields: groupFields, > hasDataToDisplay: false, > }; >+ if ( >+ groupFields.length > 0 && >+ groupFields.every(f => f.type === "group_placeholder") >+ ) { >+ groupInfo.placeholder = { >+ description: groupFields[0].description || null, >+ }; >+ groupInfo.fields = []; >+ } >+ if (groupFields.some(f => f.groupMeta?.requiresId)) { >+ groupInfo.requiresId = true; >+ } > if ( > component === "Show" && > resourceConfig.showGroupsDisplayMode === "splitScreen" && >@@ -502,11 +514,7 @@ export function useBaseResource(resourceConfig) { > } > if (component === "Show" && resource) { > groupFields.forEach(field => { >- if ( >- resource[field.name] != null && >- (field.type !== "relationshipWidget" || >- resource[field.name].length > 0) >- ) { >+ if (resource[field.name] != null) { > groupInfo.hasDataToDisplay = true; > } > }); >@@ -701,6 +709,20 @@ export function useBaseResource(resourceConfig) { > > created(); > >+ const onShowSectionSave = >+ resourceConfig.onShowSectionSave || >+ (async (sectionName, updatedResource, currentResource, isNew) => { >+ const merged = { ...currentResource, ...updatedResource }; >+ if (isNew) { >+ return await resourceConfig.apiClient.create(merged); >+ } else { >+ return await resourceConfig.apiClient.update( >+ merged, >+ merged[resourceConfig.idAttr] >+ ); >+ } >+ }); >+ > return { > ...resourceConfig, > ...moduleStoreUtils, >@@ -736,5 +758,6 @@ export function useBaseResource(resourceConfig) { > route, > router, > i18n, >+ onShowSectionSave, > }; > } >-- >2.53.0 >
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 39224
:
189234
|
189235
|
189236
|
189237
|
189238
|
189239
|
189240
|
189241
|
189242
|
189243
|
189244
|
193618
|
193619
|
193620
|
193621
|
193622
|
193623
|
193624
|
193625
|
193626
|
193627
|
193681
|
193682
|
193683
|
193684
|
193685
|
193686
|
193687
|
193688
|
193689
|
193690
|
193691
|
193692