Bug 38201: Drop extends in favour of the composition API

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:
Pedro Amorim 2025-04-21 13:44:01 +00:00 committed by Lucas Gass
parent d1e264f703
commit d101343f9d
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
7 changed files with 829 additions and 757 deletions

View File

@ -1,65 +1,49 @@
<template>
<ResourceList
v-if="routeAction === 'list'"
v-bind="{
...passCommonProps(),
...optionalResourceProps,
}"
:instancedResource="instancedResource"
@select-resource="$emit('select-resource', $event)"
>
<template #toolbar="{ resource, componentPropData }">
<Toolbar
v-if="!optionalResourceProps.embedded"
:toolbarButtons="toolbarButtons"
:toolbarButtons="instancedResource.toolbarButtons"
component="list"
:resource="resource"
:i18n="i18n"
:i18n="instancedResource.i18n"
:componentPropData="componentPropData"
/>
</template>
<template #filters="{ table }">
<ResourceListFilters
v-if="optionalResourceProps.addFiltersToList"
:tableFilters="tableFilters"
:embedded="optionalResourceProps.embedded"
:filterTable="filterTable"
:getFilterValues="getFilterValues"
:label="getTableFilterFormElementsLabel()"
v-if="instancedResource.addFiltersToList"
:instancedResource="instancedResource"
:table="table"
/>
</template>
</ResourceList>
<ResourceShow
v-if="routeAction === 'show'"
v-bind="{
...passCommonProps(),
...optionalResourceProps,
}"
:instancedResource="instancedResource"
>
<template #toolbar="{ resource, componentPropData }">
<Toolbar
:toolbarButtons="toolbarButtons"
:toolbarButtons="instancedResource.toolbarButtons"
component="show"
:resource="resource"
:i18n="i18n"
:i18n="instancedResource.i18n"
:componentPropData="componentPropData"
/>
</template>
</ResourceShow>
<ResourceFormAdd
v-if="['add', 'edit'].includes(routeAction)"
v-bind="{
...passCommonProps(),
newResource,
onSubmit,
...optionalResourceProps,
}"
:instancedResource="instancedResource"
/>
</template>
<script>
import { inject } from "vue";
import { build_url } from "../composables/datatables";
import ResourceListFilters from "./ResourceListFilters.vue";
import ResourceShow from "./ResourceShow.vue";
import ResourceFormAdd from "./ResourceFormAdd.vue";
@ -67,6 +51,10 @@ import ResourceList from "./ResourceList.vue";
import Toolbar from "./Toolbar.vue";
export default {
props: {
routeAction: String,
instancedResource: Object,
},
components: {
ResourceListFilters,
ResourceShow,
@ -75,571 +63,28 @@ export default {
Toolbar,
},
setup(props) {
const { setConfirmationDialog, setMessage, setError, setWarning } =
inject("mainStore");
const AVStore = inject("AVStore");
const { get_lib_from_av, map_av_dt_filter } = AVStore;
const format_date = $date;
const patron_to_html = $patron_to_html;
return {
...props,
setConfirmationDialog,
setMessage,
setError,
setWarning,
format_date,
patron_to_html,
...(typeof logged_in_user !== "undefined" && { logged_in_user }),
escape_str,
get_lib_from_av,
map_av_dt_filter,
build_url,
};
},
data() {
const optionalResourceProps = {
embedded: this.embedded || false,
embedded: this.instancedResource.embedded || false,
extendedAttributesResourceType:
this.extendedAttributesResourceType || null,
addFiltersToList: this.addFiltersToList || null,
formGroupsDisplayMode: this.formGroupsDisplayMode || null,
appendToShow: this.appendToShow || (() => []),
nameAttr: this.nameAttr || null,
idAttr: this.idAttr || null,
this.instancedResource.extendedAttributesResourceType || null,
addFiltersToList: this.instancedResource.addFiltersToList || null,
formGroupsDisplayMode:
this.instancedResource.formGroupsDisplayMode || null,
appendToShow: this.instancedResource.appendToShow || (() => []),
nameAttr: this.instancedResource.nameAttr || null,
idAttr: this.instancedResource.idAttr || null,
};
return {
resourceToBeGenerated: {},
optionalResourceProps,
refreshTemplate: false,
};
},
methods: {
/**
* Returns an object containing common props that are passed to all components
* in this resource component hierarchy.
*
* @return {Object} The object containing the common props.
*/
passCommonProps() {
const commonProps = {
idAttr: this.idAttr,
apiClient: this.apiClient,
i18n: this.i18n,
tableOptions: this.tableOptions,
goToResourceShow: this.goToResourceShow,
goToResourceEdit: this.goToResourceEdit,
doResourceDelete: this.doResourceDelete,
goToResourceAdd: this.goToResourceAdd,
doResourceSelect: this.doResourceSelect,
getResourceShowURL: this.getResourceShowURL,
hasAdditionalFields: this.hasAdditionalFields,
getFieldGroupings: this.getFieldGroupings,
resourceAttrs: this.resourceAttrs,
listComponent: this.listComponent,
resourceNamePlural: this.resourceNamePlural,
getResource: this.getResource,
key: this.refreshTemplate,
};
return commonProps;
},
/**
* Navigates to the show page of the given resource.
*
* @param {Object} [resource] - The resource to navigate to
* @param {DataTable} [dt] - The DataTable (optional)
* @param {Event} [event] - The event to prevent default handling of (optional)
* @return {void}
*/
goToResourceShow(resource, dt, event) {
event?.preventDefault();
this.$router.push({
name: this.showComponent,
params: { [this.idAttr]: resource[this.idAttr] },
});
},
/**
* Generates the URL for the show page of the given resource.
*
* @param {Object} resource - The resource to generate the URL for
* @return {string} The URL for the show page of the given resource
*/
getResourceShowURL(id) {
return this.$router.resolve({
name: this.showComponent,
params: { [this.idAttr]: id },
}).href;
},
/**
* Navigates to the edit page of the given resource.
*
* @param {Object} resource - The resource to navigate to (optional)
* @return {void}
*/
goToResourceEdit(resource) {
this.$router.push({
name: this.editComponent,
params: {
[this.idAttr]: resource
? resource[this.idAttr]
: this.newResource[this.idAttr],
},
});
},
/**
* Navigates to the creation page of the given resource.
*
* @return {void}
*/
goToResourceAdd() {
this.$router.push({
name: this.addComponent,
});
},
/**
* Navigates to the list page of the given resource.
*
* @return {void}
*/
goToResourceList() {
this.$router.push({
name: this.listComponent,
});
},
/**
* Resource deletion handler.
* Accepts an optional callback function to run after deletion.
* If no callback is provided, does the following:
* - If deleting from show component, navigates to resource list component.
* - If deleting from resource list component, redraws the table.
*
* @param {Object} resource - The resource to delete (optional)
* @param {Object} callback - Callback to call after deletion (optional)
* @return {void}
*/
doResourceDelete(resource, callback) {
let resourceId = resource
? resource[this.idAttr]
: this.newResource[this.idAttr];
let resourceName = resource
? resource[this.nameAttr]
: this.newResource[this.nameAttr];
this.setConfirmationDialog(
{
title: this.i18n.deleteConfirmationMessage,
message: resourceName,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
this.apiClient.delete(resourceId).then(
success => {
this.setMessage(
this.i18n.deleteSuccessMessage.format(
resourceName
),
true
);
if (typeof callback === "function") {
callback();
} else {
if (this.$props.routeAction === "list") {
callback.ajax.reload();
} else {
this.goToResourceList();
}
}
},
error => {}
);
}
);
},
/**
* Return the URL for the resource table.
*
* @return {string}
*/
getResourceTableUrl() {
return this.resourceTableUrl;
},
/**
* Emits the 'select-resource' event with the id of the provided resource.
*
* @param {Object} resource - The resource object containing the id attribute.
* @param {Object} dt - DataTables instance (not used in this function).
* @param {Event} event - The event object (not used in this function).
*/
doResourceSelect(resource, dt, event) {
this.$emit("select-resource", resource[this.idAttr]);
},
/**
* Populates the resource attributes with authorised values based on their types.
*
* For each attribute:
* - If the attribute type is "select" and it has an authorised value category (avCat),
* it sets the options from the component's corresponding property.
* - If the attribute type is "relationship" and it has component properties,
* it assigns authorised values to any marked as type 'av'.
* - If the attribute has relationship fields, the function is recursively called
* to populate them as well.
*
* @param {Array} attrs - The array of attributes to be populated with authorised values.
*/
populateAttributesWithAuthorisedValues(attrs) {
if (!attrs) return;
attrs.forEach(attr => {
if (attr.type === "select" && typeof attr.avCat === "string") {
const avKey = attr.avCat;
attr.options = this[avKey];
attr.requiredKey = "value";
attr.selectLabel = "description";
}
if (attr.type == "relationship" && attr.componentProps) {
Object.keys(attr.componentProps).forEach(key => {
if (attr.componentProps[key].type == "av") {
attr.componentProps[key].av = this[key];
}
});
}
if (attr.relationshipFields?.length) {
this.populateAttributesWithAuthorisedValues(
attr.relationshipFields
);
}
});
},
/**
* Updates the extended_attributes property of the provided resource
*
* @param {Array} additionalFieldValues - Array of objects with
* name and value properties.
* @param {Object} resource - The resource object whose
* extended_attributes property is updated.
*/
additionalFieldsChanged(additionalFieldValues, resource) {
resource.extended_attributes = additionalFieldValues;
},
/**
* Builds an object of filter name-value pairs based on the provided
* query object and filterData (tableFilters by default).
*
* Iterates over the query object keys and updates the filterOptions
* object with the new values. If a filter name is not found in
* filterOptions, it is added.
*
* @param {Object} query - The query object (taken from the URL params)containing the filter values.
* @param {Array} filterData - The array of filter objects (optional).
* @return {Object}
*/
getFilterValues(query, filterData) {
const filters = filterData
? filterData
: this.tableFilters
? this.tableFilters
: [];
const filterOptions = filters.reduce((acc, filter) => {
acc[filter.name] = filter.value;
return acc;
}, {});
Object.keys(query).forEach(key => {
if (
filterOptions.hasOwnProperty(key) &&
query[key] !== filterOptions[key]
) {
filterOptions[key] = query[key];
}
if (!filterOptions.hasOwnProperty(key)) {
filterOptions[key] = query[key];
}
});
return filterOptions;
},
/**
* Gets the label to display before the filters
*
* @return {String} The label
*/
getTableFilterFormElementsLabel() {
return "";
},
/**
* Gets the array of filters for the table, if required.
* This is a default method that returns an empty array.
* It can be overridden at the resource level if filters are required
*
* @return {Array} The array of filters for the table.
*/
getTableFilterFormElements() {
return [];
},
/**
* Gets the list of default buttons to add to the toolbar, for each view: list, show, edit
* It can be overridden at the resource level if the default buttons are not required
*
* @return {Object} keys must be "list", "show" or "edit", values are functions.
*/
defaultToolbarButtons(resource, i18n) {
return {
list: [
{
action: "add",
onClick: () => this.goToResourceAdd(),
title: i18n.newLabel,
index: 0,
},
],
show: [
{
action: "edit",
onClick: () => this.goToResourceEdit(resource),
title: this.$__("Edit"),
index: 0,
},
{
action: "delete",
onClick: () => this.doResourceDelete(resource),
title: this.$__("Delete"),
index: 1,
},
],
};
},
/**
* Returns a default empty set of additional buttons
* Additional buttons should be added in the resource specific component
*
* @returns {Object}
*/
additionalToolbarButtons() {
return {
list: [],
show: [],
};
},
/**
* This method takes a component name (e.g. 'Form' or 'Show') and an optional resource object
* and returns an array of grouped objects that determine which fields should be grouped together
* in the relevant component. Each group object contains the name of the group and an array of the
* field objects that belong to that group.
*
* It first filters the resource attributes to only include those that are not hidden
* in the given component. Then it groups the attributes by the group name
* (or "noGroupFound" if there is no group name). If the component is 'Show', it also
* checks if the resource object has data to display in each group. If not,
* the group is not included.
*
* @param {String} component - The component name (e.g. 'Form' or 'Show').
* @param {Object} resource - The resource object (optional).
* @return {Array} The array of group objects.
*/
getFieldGroupings(component, resource) {
const attributesToConsider = this.resourceAttrs.reduce(
(acc, ra) => {
if (ra.hideIn && !ra.hideIn.includes(component)) {
return [...acc, ra];
}
if (ra.hideIn && ra.hideIn.includes(component)) {
return acc;
}
return [...acc, ra];
},
[]
);
if (this.extendedAttributesResourceType) {
attributesToConsider.push({
name: "additional_fields",
type: "additional_fields",
extended_attributes_resource_type:
this.extendedAttributesResourceType,
});
}
const groupings = attributesToConsider.reduce((acc, attr) => {
if (
attr.hasOwnProperty("group") &&
attr.group !== null &&
!acc.includes(attr.group)
) {
return [...acc, attr.group];
}
if (!attr.hasOwnProperty("group")) {
attr.group = "noGroupFound";
if (!acc.includes("noGroupFound")) {
return [...acc, "noGroupFound"];
}
}
return acc;
}, []);
if (groupings.length === 0) {
return [
{
name: null,
fields: attributesToConsider,
},
];
}
// FIXME - if no group is defined in accordion mode then the section doesn't have the dropdown applied
return groupings.reduce((acc, group) => {
const groupFields = attributesToConsider.filter(
ra => ra.group === group
);
const groupInfo = {
name: group === "noGroupFound" ? null : group,
fields: groupFields,
hasDataToDisplay: false,
};
if (component === "Show" && resource) {
groupFields.forEach(field => {
if (
resource[field.name] != null &&
(field.type !== "relationshipWidget" ||
resource[field.name].length > 0)
) {
groupInfo.hasDataToDisplay = true;
}
});
if (!groupInfo.hasDataToDisplay) {
return [...acc];
}
}
return [...acc, groupInfo];
}, []);
},
async getResource(resourceId, componentData, caller) {
this.apiClient.get(resourceId).then(
resource => {
componentData.resource = resource;
if (this.afterResourceFetch) {
this.afterResourceFetch(
componentData,
resource,
caller
);
}
componentData.initialized = true;
},
error => {}
);
},
refreshTemplateState() {
this.refreshTemplate = !this.refreshTemplate;
},
},
computed: {
/**
* Generates a new resource object with default values.
*
* This method initializes the `resourceToBeGenerated` property by iterating
* over `resourceAttrs`, assigning default values based on attribute types.
* - If an attribute has a `defaultValue`, it is set accordingly.
* - For text, textarea, and select types, an empty string is assigned.
* - For boolean and checkbox types, a false value is assigned.
* - For additional fields or relationship widgets, an empty array is assigned
* to `extended_attributes` or the attribute name.
* - If none of the above apply, a null value is assigned.
* The `idAttr` of the resource is also initialized to null.
*
* @return {Object} The newly generated resource object with default values.
*/
newResource() {
this.resourceToBeGenerated = this.resourceAttrs.reduce(
(acc, attr) => {
if (attr.hasOwnProperty("defaultValue")) {
acc[attr.name] = attr.defaultValue;
return acc;
}
if (["text", "textarea", "select"].includes(attr.type)) {
acc[attr.name] = "";
return acc;
}
if (["boolean", "checkbox"].includes(attr.type)) {
acc[attr.name] = false;
return acc;
}
if (attr.type === "relationshipWidget") {
acc[attr.name] = [];
return acc;
}
acc[attr.name] = null;
return acc;
},
{}
);
if (this.extendedAttributesResourceType) {
this.resourceToBeGenerated.extended_attributes = [];
}
this.resourceToBeGenerated[this.idAttr] = null;
return this.resourceToBeGenerated;
},
/**
* Determines if the resource has additional fields.
*
* @return {Boolean} true if the resource has additional fields, false otherwise.
*/
hasAdditionalFields() {
return !!this.extendedAttributesResourceType;
},
/**
* Returns the plural form of the resource name.
*
* This method checks if the `resourceName` ends with 's'. If it does not,
* it appends 's' to create the plural form. Otherwise returns `resourceName`.
*
* @return {String} The plural form of the resource name.
*/
resourceNamePlural() {
if (!this.resourceName.endsWith("s"))
return this.resourceName + "s";
return this.resourceName;
},
/**
* A function that returns a set of buttons to display in the toolbar, based on the current resource and component.
* The function takes three arguments: the resource, the component and the i18n object.
* It returns an array of buttons. The buttons are a combination of the default buttons and the additional buttons.
* The default buttons are defined in the defaultToolbarButtons function and the additional buttons are defined in the
* additionalToolbarButtons function at the resource level.
*
* @param {Object} resource The current resource
* @param {String} component The current component
* @param {Object} i18n The i18n object
* @return {Array<Object>} An array of buttons
*/
toolbarButtons() {
const defaultToolbarButtons = this.defaultToolbarButtons;
const additionalToolbarButtons = this.additionalToolbarButtons;
return (resource, component, i18n, componentData) => {
const defaultButtons = defaultToolbarButtons(resource, i18n);
const additionalButtons = additionalToolbarButtons(
resource,
componentData
);
//FIXME: we need to check that no indexes match between the default buttons and additional buttons
// If we add to the default buttons in future it could mess up indexing
return [
...(defaultButtons[component] || []),
...(additionalButtons[component] || []).filter(
button => Object.keys(button).length > 0
),
].sort((a, b) => a.index - b.index);
};
},
},
created() {
if (this.resourceAttrs) {
this.populateAttributesWithAuthorisedValues(this.resourceAttrs);
}
},
name: "BaseResource",
};
</script>

View File

@ -1,16 +1,26 @@
<template>
<BaseResource
:routeAction="routeAction"
:instancedResource="this"
></BaseResource>
</template>
<script>
import { inject } from "vue";
import BaseResource from "../BaseResource.vue";
import { useBaseResource } from "../../composables/base-resource.js";
import { storeToRefs } from "pinia";
import { APIClient } from "../../fetch/api-client.js";
export default {
extends: BaseResource,
// extends: BaseResource,
props: {
routeAction: String,
embedded: { type: Boolean, default: false },
},
setup(props) {
const format_date = $date;
const patron_to_html = $patron_to_html;
const AVStore = inject("AVStore");
const {
av_agreement_statuses,
@ -25,52 +35,74 @@ export default {
const vendorStore = inject("vendorStore");
const { vendors } = storeToRefs(vendorStore);
return {
...BaseResource.setup({
resourceName: "agreement",
nameAttr: "name",
idAttr: "agreement_id",
showComponent: "AgreementsShow",
listComponent: "AgreementsList",
addComponent: "AgreementsFormAdd",
editComponent: "AgreementsFormAddEdit",
apiClient: APIClient.erm.agreements,
resourceTableUrl:
APIClient.erm.httpClient._baseURL + "agreements",
i18n: {
deleteConfirmationMessage: __(
"Are you sure you want to remove this agreement?"
),
deleteSuccessMessage: __("Agreement %s deleted"),
displayName: __("Agreement"),
editLabel: __("Edit agreement #%s"),
emptyListMessage: __("There are no agreements defined"),
newLabel: __("New agreement"),
const getTableFilterFormElements = () => {
return [
{
name: "by_expired",
type: "checkbox",
label: __("Filter by expired"),
value: false,
onChange: function (filters) {
if (filters.by_expired) {
filters.max_expiration_date = new Date()
.toISOString()
.substring(0, 10);
} else {
filters.max_expiration_date = "";
}
return filters;
},
},
embedded: props.embedded,
extendedAttributesResourceType: "agreement",
addFiltersToList: true,
av_agreement_statuses,
av_agreement_closure_reasons,
av_agreement_renewal_priorities,
av_user_roles,
av_agreement_license_statuses,
av_agreement_license_location,
av_agreement_relationships,
agreement_table_settings,
vendors,
}),
{
name: "max_expiration_date",
type: "date",
label: __("on"),
componentProps: {
disabled: {
resourceProperty: "by_expired",
qualifier: "!",
},
},
value: "",
},
{
name: "by_mine",
type: "checkbox",
label: __("Show mine only"),
value: false,
},
];
};
},
data() {
const tableFilters = this.getTableFilterFormElements();
const defaults = this.getFilterValues(this.$route.query, tableFilters);
return {
const extendedAttributesResourceType = "agreement";
const baseResource = useBaseResource({
resourceName: "agreement",
nameAttr: "name",
idAttr: "agreement_id",
showComponent: "AgreementsShow",
listComponent: "AgreementsList",
addComponent: "AgreementsFormAdd",
editComponent: "AgreementsFormAddEdit",
apiClient: APIClient.erm.agreements,
resourceTableUrl: APIClient.erm.httpClient._baseURL + "agreements",
i18n: {
deleteConfirmationMessage: __(
"Are you sure you want to remove this agreement?"
),
deleteSuccessMessage: __("Agreement %s deleted"),
displayName: __("Agreement"),
editLabel: __("Edit agreement #%s"),
emptyListMessage: __("There are no agreements defined"),
newLabel: __("New agreement"),
},
embedded: props.embedded,
addFiltersToList: true,
extendedAttributesResourceType,
resourceAttrs: [
{
name: this.idAttr,
label: this.$__("ID"),
name: "agreement_id",
label: __("ID"),
type: "text",
hideIn: ["Form", "Show"],
},
@ -147,17 +179,17 @@ export default {
{
name: this.$__("Period start"),
value: "started_on",
format: this.format_date,
format: format_date,
},
{
name: this.$__("Period end"),
value: "ended_on",
format: this.format_date,
format: format_date,
},
{
name: this.$__("Cancellation deadline"),
value: "cancellation_deadline",
format: this.format_date,
format: format_date,
},
{
name: this.$__("Notes"),
@ -240,7 +272,7 @@ export default {
{
name: this.$__("Name"),
value: "patron",
format: this.patron_to_html,
format: patron_to_html,
},
{
name: this.$__("Role"),
@ -609,6 +641,30 @@ export default {
hideIn: ["List"],
},
],
av_agreement_statuses,
av_agreement_closure_reasons,
av_agreement_renewal_priorities,
av_user_roles,
av_agreement_license_statuses,
av_agreement_license_location,
av_agreement_relationships,
agreement_table_settings,
vendors,
tableFilters: getTableFilterFormElements(),
props: props,
});
baseResource.created();
return {
...baseResource,
};
},
data() {
const defaults = this.getFilterValues(
this.$route.query,
this.tableFilters
);
return {
tableOptions: {
options: {
embed: "user_roles,vendor,extended_attributes,+strings",
@ -657,7 +713,6 @@ export default {
},
},
},
tableFilters,
};
},
methods: {
@ -794,44 +849,6 @@ export default {
);
}
},
getTableFilterFormElements() {
return [
{
name: "by_expired",
type: "checkbox",
label: this.$__("Filter by expired"),
value: false,
onChange: function (filters) {
if (filters.by_expired) {
filters.max_expiration_date = new Date()
.toISOString()
.substring(0, 10);
} else {
filters.max_expiration_date = "";
}
return filters;
},
},
{
name: "max_expiration_date",
type: "date",
label: this.$__("on"),
componentProps: {
disabled: {
resourceProperty: "by_expired",
qualifier: "!",
},
},
value: "",
},
{
name: "by_mine",
type: "checkbox",
label: this.$__("Show mine only"),
value: false,
},
];
},
tableUrl(filters) {
let url = this.getResourceTableUrl();
if (filters?.by_expired)
@ -857,5 +874,8 @@ export default {
},
emits: ["select-resource"],
name: "AgreementResource",
components: {
BaseResource,
},
};
</script>

View File

@ -1,17 +1,23 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else :id="`${resourceNamePlural}_add`">
<h2 v-if="resourceToAddOrEdit[idAttr]">
{{ i18n.editLabel.format(resourceToAddOrEdit[idAttr]) }}
<div v-else :id="`${instancedResource.resourceNamePlural}_add`">
<h2 v-if="resourceToAddOrEdit[instancedResource.idAttr]">
{{
instancedResource.i18n.editLabel.format(
resourceToAddOrEdit[instancedResource.idAttr]
)
}}
</h2>
<h2 v-else>{{ i18n.newLabel }}</h2>
<h2 v-else>{{ instancedResource.i18n.newLabel }}</h2>
<ul
v-if="formGroupsDisplayMode == 'tabs'"
v-if="instancedResource.formGroupsDisplayMode == 'tabs'"
class="nav nav-tabs"
role="tablist"
>
<li
v-for="(tab, counter) in getFieldGroupings('Form')"
v-for="(tab, counter) in instancedResource.getFieldGroupings(
'Form'
)"
class="nav-item"
:key="`tab${counter}`"
>
@ -27,10 +33,15 @@
>
</li>
</ul>
<form @submit="onSubmit($event, resourceToAddOrEdit)">
<div v-if="formGroupsDisplayMode == 'tabs'" class="tab-content">
<form @submit="instancedResource.onSubmit($event, resourceToAddOrEdit)">
<div
v-if="instancedResource.formGroupsDisplayMode == 'tabs'"
class="tab-content"
>
<div
v-for="(group, counter) in getFieldGroupings('Form')"
v-for="(
group, counter
) in instancedResource.getFieldGroupings('Form')"
v-bind:key="counter"
:id="group.name?.replace(/\s/g, '_')"
role="tabpanel"
@ -59,9 +70,15 @@
</fieldset>
</div>
</div>
<div v-else-if="formGroupsDisplayMode == 'accordion'">
<div
v-else-if="
instancedResource.formGroupsDisplayMode == 'accordion'
"
>
<div
v-for="(group, counter) in getFieldGroupings('Form')"
v-for="(
group, counter
) in instancedResource.getFieldGroupings('Form')"
v-bind:key="counter"
class="accordion"
>
@ -106,7 +123,9 @@
</div>
<div v-else>
<fieldset
v-for="(group, counter) in getFieldGroupings('Form')"
v-for="(
group, counter
) in instancedResource.getFieldGroupings('Form')"
v-bind:key="counter"
class="rows"
>
@ -128,7 +147,7 @@
<fieldset class="action">
<ButtonSubmit />
<router-link
:to="{ name: listComponent }"
:to="{ name: instancedResource.listComponent }"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
@ -139,6 +158,7 @@
</template>
<script>
import { reactive } from "vue";
import FormElement from "./FormElement.vue";
import ButtonSubmit from "./ButtonSubmit.vue";
@ -151,27 +171,24 @@ export default {
};
},
props: {
idAttr: String,
apiClient: Object,
i18n: Object,
listComponent: String,
newResource: Object,
onSubmit: Function,
resourceNamePlural: String,
getFieldGroupings: Function,
formGroupsDisplayMode: String,
getResource: Function,
instancedResource: Object,
},
created() {
if (this.$route.params[this.idAttr]) {
this.getResource(this.$route.params[this.idAttr], this, "form");
if (this.$route.params[this.instancedResource.idAttr]) {
this.instancedResource.getResource(
this.$route.params[this.instancedResource.idAttr],
this,
"form"
);
} else {
this.initialized = true;
}
},
computed: {
resourceToAddOrEdit() {
return this.resource || this.newResource;
return (
this.resource || reactive(this.instancedResource.newResource)
);
},
},
components: {

View File

@ -1,6 +1,6 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else :id="`${resourceNamePlural}_list`">
<div v-else :id="`${instancedResource.resourceNamePlural}_list`">
<slot name="toolbar" :componentPropData="{ ...$props, ...$data }" />
<template v-if="resourceCount > 0">
<slot name="filters" :table="table" />
@ -15,7 +15,7 @@
></KohaTable>
</div>
<div v-else class="alert alert-info">
{{ i18n.emptyListMessage }}
{{ instancedResource.i18n.emptyListMessage }}
</div>
</div>
</template>
@ -40,22 +40,7 @@ export default {
};
},
props: {
embedded: Boolean,
apiClient: Object,
i18n: Object,
tableOptions: Object,
goToResourceShow: Function,
goToResourceEdit: Function,
doResourceDelete: Function,
doResourceSelect: Function,
getResourceShowURL: Function,
resourceName: String,
resourceNamePlural: String,
hasAdditionalFields: { type: Boolean, default: false },
extendedAttributesResourceType: String,
resourceAttrs: Array,
nameAttr: String,
idAttr: String,
instancedResource: Object,
},
data() {
return {
@ -64,19 +49,21 @@ export default {
searchable_additional_fields: [],
searchable_av_options: [],
tableEvents: {
show: this.goToResourceShow,
edit: this.goToResourceEdit,
delete: this.doResourceDelete,
select: this.doResourceSelect,
show: this.instancedResource.goToResourceShow,
edit: this.instancedResource.goToResourceEdit,
delete: this.instancedResource.doResourceDelete,
select: this.instancedResource.doResourceSelect,
},
};
},
created() {
if (this.embedded) {
this.getResourceCount().then(() => (this.initialized = true));
if (this.instancedResource.embedded) {
this.getResourceCount().then(
() => (this.instancedResource.initialized = true)
);
} else {
this.getResourceCount().then(() => {
if (this.hasAdditionalFields) {
if (this.instancedResource.hasAdditionalFields) {
this.getSearchableAdditionalFields().then(() =>
this.getSearchableAVOptions().then(
() => (this.initialized = true)
@ -90,7 +77,7 @@ export default {
},
methods: {
async getResourceCount() {
await this.apiClient.count().then(
await this.instancedResource.apiClient.count().then(
count => {
this.resourceCount = count;
},
@ -100,7 +87,7 @@ export default {
async getSearchableAdditionalFields() {
const client = APIClient.additional_fields;
await client.additional_fields
.getAll(this.extendedAttributesResourceType)
.getAll(this.instancedResource.extendedAttributesResourceType)
.then(
searchable_additional_fields => {
this.searchable_additional_fields =
@ -135,8 +122,8 @@ export default {
});
},
getTableColumns(resourceAttrs) {
let get_lib_from_av = this.get_lib_from_av;
let thisResource = this;
let get_lib_from_av = this.instancedResource.get_lib_from_av;
let thisResource = this.instancedResource;
const columns = resourceAttrs.reduce((acc, attr, i) => {
if (
@ -151,7 +138,7 @@ export default {
acc.push(attr.tableColumnDefinition);
return acc;
}
if (attr.name === this.idAttr) {
if (attr.name === this.instancedResource.idAttr) {
acc.push({
title: attr.label,
data: attr.name,
@ -171,7 +158,7 @@ export default {
});
return acc;
}
if (attr.name === this.nameAttr) {
if (attr.name === this.instancedResource.nameAttr) {
acc.push({
title: attr.label,
data: attr.name,
@ -264,22 +251,21 @@ export default {
},
computed: {
tableOptionsWithColumns() {
this.tableOptions.columns = this.getTableColumns(
this.resourceAttrs
this.instancedResource.tableOptions.columns = this.getTableColumns(
this.instancedResource.resourceAttrs
);
return this.tableOptions;
return this.instancedResource.tableOptions;
},
tableEventList() {
const actionButtons = this.tableOptions.actions["-1"].reduce(
(acc, curr) => {
if (typeof curr === "object") {
const actionName = Object.keys(curr)[0];
acc[actionName] = curr[actionName].callback;
}
return acc;
},
{}
);
const actionButtons = this.instancedResource.tableOptions.actions[
"-1"
].reduce((acc, curr) => {
if (typeof curr === "object") {
const actionName = Object.keys(curr)[0];
acc[actionName] = curr[actionName].callback;
}
return acc;
}, {});
return { ...this.tableEvents, ...actionButtons };
},
},

View File

@ -1,11 +1,23 @@
<template>
<fieldset v-if="tableFilters?.length > 0" class="filters">
<template v-if="label">{{ label }}{{ " " }}</template>
<template v-for="(filter, index) in tableFilters" v-bind:key="index">
<fieldset v-if="instancedResource.tableFilters?.length > 0" class="filters">
<template v-if="instancedResource.getTableFilterFormElementsLabel()"
>{{ instancedResource.getTableFilterFormElementsLabel()
}}{{ " " }}</template
>
<template
v-for="(filter, index) in instancedResource.tableFilters"
v-bind:key="index"
>
<FormElement :resource="filters" :attr="filter" :index="index" />
</template>
<input
@click="filterTable(filters, table, embedded)"
@click="
instancedResource.filterTable(
filters,
table,
instancedResource.embedded
)
"
id="filterTable"
type="button"
:value="$__('Filter')"
@ -18,17 +30,13 @@ import FormElement from "./FormElement.vue";
export default {
components: { FormElement },
props: {
embedded: Boolean,
tableFilters: { type: Array, default: [] },
getFilterValues: Function,
filterTable: Function | null,
label: String | "",
instancedResource: Object,
table: Object,
},
data() {
return {
filters: this.getFilterValues
? this.getFilterValues(this.$route.query)
filters: this.instancedResource.getFilterValues
? this.instancedResource.getFilterValues(this.$route.query)
: {},
};
},

View File

@ -1,18 +1,25 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else :id="`${resourceNamePlural}_show`">
<div v-else :id="`${instancedResource.resourceNamePlural}_show`">
<slot
name="toolbar"
:resource="resource"
:componentPropData="{ ...$props, ...$data }"
/>
<h2>
{{ i18n.displayName + " #" + resource[idAttr] }}
{{
instancedResource.i18n.displayName +
" #" +
resource[instancedResource.idAttr]
}}
</h2>
<div>
<fieldset
class="rows"
v-for="(group, counter) in getFieldGroupings('Show', resource)"
v-for="(group, counter) in instancedResource.getFieldGroupings(
'Show',
resource
)"
v-bind:key="counter"
>
<legend v-if="group.name">{{ group.name }}</legend>
@ -27,7 +34,7 @@
</fieldset>
<fieldset
class="rows"
v-for="(item, counter) in appendToShow(this)"
v-for="(item, counter) in instancedResource.appendToShow(this)"
v-bind:key="counter"
>
<legend v-if="item.name">{{ item.name }}</legend>
@ -35,7 +42,7 @@
</fieldset>
<fieldset class="action">
<router-link
:to="{ name: listComponent }"
:to="{ name: instancedResource.listComponent }"
role="button"
class="cancel"
>{{ $__("Close") }}</router-link
@ -57,18 +64,14 @@ export default {
};
},
props: {
idAttr: String,
apiClient: Object,
i18n: Object,
listComponent: String,
resourceNamePlural: String,
getFieldGroupings: Function,
appendToShow: Function,
afterResourceFetch: Function,
getResource: Function,
instancedResource: Object,
},
created() {
this.getResource(this.$route.params[this.idAttr], this, "show");
this.instancedResource.getResource(
this.$route.params[this.instancedResource.idAttr],
this,
"show"
);
},
components: { Toolbar, ShowElement },
name: "ResourceShow",

View File

@ -0,0 +1,593 @@
import { computed, inject } from "vue";
import { useRouter } from "vue-router";
import { $__ } from "../i18n";
import { build_url } from "../composables/datatables";
export function useBaseResource(context) {
const router = useRouter();
const { setConfirmationDialog, setMessage, setError, setWarning } =
inject("mainStore");
const AVStore = inject("AVStore");
const { get_lib_from_av, map_av_dt_filter } = AVStore;
const format_date = $date;
const patron_to_html = $patron_to_html;
const instancedResource = {
...Object.keys(context).reduce((acc, key) => {
acc[key] = context[key];
return acc;
}, {}),
};
/**
* Navigates to the creation page of the given resource.
*
* @return {void}
*/
const goToResourceAdd = () => {
router.push({
name: instancedResource.addComponent,
});
};
/**
* Navigates to the edit page of the given resource.
*
* @param {Object} resource - The resource to navigate to (optional)
* @return {void}
*/
const goToResourceEdit = resource => {
router.push({
name: instancedResource.editComponent,
params: {
[instancedResource.idAttr]: resource
? resource[instancedResource.idAttr]
: instancedResource.newResource[instancedResource.idAttr],
},
});
};
/**
* Return the URL for the resource table.
*
* @return {string}
*/
const getResourceTableUrl = () => {
return instancedResource.resourceTableUrl;
};
/**
* Generates the URL for the show page of the given resource.
*
* @param {Object} resource - The resource to generate the URL for
* @return {string} The URL for the show page of the given resource
*/
const getResourceShowURL = id => {
return router.resolve({
name: instancedResource.showComponent,
params: { [instancedResource.idAttr]: id },
}).href;
};
/**
* Navigates to the show page of the given resource.
*
* @param {Object} [resource] - The resource to navigate to
* @param {DataTable} [dt] - The DataTable (optional)
* @param {Event} [event] - The event to prevent default handling of (optional)
* @return {void}
*/
const goToResourceShow = (resource, dt, event) => {
event?.preventDefault();
router.push({
name: instancedResource.showComponent,
params: {
[instancedResource.idAttr]: resource[instancedResource.idAttr],
},
});
};
/**
* Builds an object of filter name-value pairs based on the provided
* query object and filterData (tableFilters by default).
*
* Iterates over the query object keys and updates the filterOptions
* object with the new values. If a filter name is not found in
* filterOptions, it is added.
*
* @param {Object} query - The query object (taken from the URL params)containing the filter values.
* @param {Array} filterData - The array of filter objects (optional).
* @return {Object}
*/
const getFilterValues = (query, filterData) => {
const filters = filterData
? filterData
: instancedResource.tableFilters
? instancedResource.tableFilters
: [];
const filterOptions = filters.reduce((acc, filter) => {
acc[filter.name] = filter.value;
return acc;
}, {});
Object.keys(query).forEach(key => {
if (
filterOptions.hasOwnProperty(key) &&
query[key] !== filterOptions[key]
) {
filterOptions[key] = query[key];
}
if (!filterOptions.hasOwnProperty(key)) {
filterOptions[key] = query[key];
}
});
return filterOptions;
};
/**
* Builds an object of filter name-value pairs based on the provided
* query object and filterData (tableFilters by default).
*
* Iterates over the query object keys and updates the filterOptions
* object with the new values. If a filter name is not found in
* filterOptions, it is added.
*
* @param {Object} query - The query object (taken from the URL params)containing the filter values.
* @param {Array} filterData - The array of filter objects (optional).
* @return {Object}
*/
const getFilters = (query, filterData) => {
const filters = filterData
? filterData
: instancedResource.tableFilters
? instancedResource.tableFilters
: [];
const filterOptions = filters.reduce((acc, filter) => {
acc[filter.name] = filter.value;
return acc;
}, {});
};
/**
* Gets the list of default buttons to add to the toolbar, for each view: list, show, edit
* It can be overridden at the resource level if the default buttons are not required
*
* @return {Object} keys must be "list", "show" or "edit", values are functions.
*/
const defaultToolbarButtons = (resource, i18n) => {
return {
list: [
{
action: "add",
onClick: () => goToResourceAdd(),
title: i18n.newLabel,
index: 0,
},
],
show: [
{
action: "edit",
onClick: () => goToResourceEdit(resource),
title: __("Edit"),
index: 0,
},
{
action: "delete",
onClick: () => doResourceDelete(resource),
title: __("Delete"),
index: 1,
},
],
};
};
/**
* Returns a default empty set of additional buttons
* Additional buttons should be added in the resource specific component
*
* @returns {Object}
*/
const additionalToolbarButtons = () => {
return {
list: [],
show: [],
};
};
const appendToShow = () => {
return null;
};
/**
* This method takes a component name (e.g. 'Form' or 'Show') and an optional resource object
* and returns an array of grouped objects that determine which fields should be grouped together
* in the relevant component. Each group object contains the name of the group and an array of the
* field objects that belong to that group.
*
* It first filters the resource attributes to only include those that are not hidden
* in the given component. Then it groups the attributes by the group name
* (or "noGroupFound" if there is no group name). If the component is 'Show', it also
* checks if the resource object has data to display in each group. If not,
* the group is not included.
*
* @param {String} component - The component name (e.g. 'Form' or 'Show').
* @param {Object} resource - The resource object (optional).
* @return {Array} The array of group objects.
*/
const getFieldGroupings = (component, resource) => {
const displayProperty = `hideIn${component}`;
const attributesToConsider = instancedResource.resourceAttrs.filter(
ra => !ra.hasOwnProperty(displayProperty) || !ra[displayProperty]
);
const groupings = attributesToConsider.reduce((acc, attr) => {
if (
attr.hasOwnProperty("group") &&
attr.group !== null &&
!acc.includes(attr.group)
) {
return [...acc, attr.group];
}
if (!attr.hasOwnProperty("group")) {
attr.group = "noGroupFound";
if (!acc.includes("noGroupFound")) {
return [...acc, "noGroupFound"];
}
}
return acc;
}, []);
if (groupings.length === 0) {
return [
{
name: null,
fields: attributesToConsider,
},
];
}
// FIXME - if no group is defined in accordion mode then the section doesn't have the dropdown applied
return groupings.reduce((acc, group) => {
const groupFields = attributesToConsider.filter(
ra => ra.group === group
);
const groupInfo = {
name: group === "noGroupFound" ? null : group,
fields: groupFields,
hasDataToDisplay: false,
};
if (component === "Show" && resource) {
groupFields.forEach(field => {
if (
resource[field.name] != null &&
(field.type !== "relationshipWidget" ||
resource[field.name].length > 0)
) {
groupInfo.hasDataToDisplay = true;
}
});
if (!groupInfo.hasDataToDisplay) {
return [...acc];
}
}
return [...acc, groupInfo];
}, []);
};
/**
* Populates the resource attributes with authorised values based on their types.
*
* For each attribute:
* - If the attribute type is "select" and it has an authorised value category (avCat),
* it sets the options from the component's corresponding property.
* - If the attribute type is "relationship" and it has component properties,
* it assigns authorised values to any marked as type 'av'.
* - If the attribute has relationship fields, the function is recursively called
* to populate them as well.
*
* @param {Array} attrs - The array of attributes to be populated with authorised values.
*/
const populateAttributesWithAuthorisedValues = attrs => {
if (!attrs) return;
attrs.forEach(attr => {
if (attr.type === "select" && typeof attr.avCat === "string") {
const avKey = attr.avCat;
const avArray = instancedResource[avKey].value;
attr.options = avArray;
attr.requiredKey = "value";
attr.selectLabel = "description";
}
if (attr.type == "relationship" && attr.componentProps) {
Object.keys(attr.componentProps).forEach(key => {
if (attr.componentProps[key].type == "av") {
attr.componentProps[key].av = instancedResource[key];
}
});
}
if (attr.relationshipFields?.length) {
populateAttributesWithAuthorisedValues(attr.relationshipFields);
}
});
};
const created = () => {
if (instancedResource.resourceAttrs) {
populateAttributesWithAuthorisedValues(
instancedResource.resourceAttrs
);
}
};
const getResource = async (resourceId, componentData, caller) => {
instancedResource.apiClient.get(resourceId).then(
resource => {
//TODO: Rename this 'resource' to 'fetchedResource'. Needs to also be renamed in ResourceFormAdd and ResourceShow
// This is to make it clear that this is the fetchedResource (data), not the resource component class
componentData.resource = resource;
if (instancedResource.afterResourceFetch) {
instancedResource.afterResourceFetch(
componentData,
resource,
caller
);
}
componentData.initialized = true;
},
error => {}
);
};
/**
* Navigates to the list page of the given resource.
*
* @return {void}
*/
const goToResourceList = () => {
router.push({
name: instancedResource.listComponent,
});
};
/**
* Resource deletion handler.
* Accepts an optional callback function to run after deletion.
* If no callback is provided, does the following:
* - If deleting from show component, navigates to resource list component.
* - If deleting from resource list component, redraws the table.
*
* @param {Object} resource - The resource to delete (optional)
* @param {Object} callback - Callback to call after deletion (optional)
* @return {void}
*/
const doResourceDelete = (resource, callback) => {
let resourceId = resource
? resource[instancedResource.idAttr]
: instancedResource.newResource[instancedResource.idAttr];
let resourceName = resource
? resource[instancedResource.nameAttr]
: instancedResource.newResource[instancedResource.nameAttr];
setConfirmationDialog(
{
title: instancedResource.i18n.deleteConfirmationMessage,
message: resourceName,
accept_label: $__("Yes, delete"),
cancel_label: $__("No, do not delete"),
},
() => {
instancedResource.apiClient.delete(resourceId).then(
success => {
setMessage(
instancedResource.i18n.deleteSuccessMessage.format(
resourceName
),
true
);
if (typeof callback === "function") {
callback();
} else {
if (
instancedResource.props.routeAction === "list"
) {
callback.ajax.reload();
} else {
goToResourceList();
}
}
},
error => {}
);
}
);
};
/**
* Emits the 'select-resource' event with the id of the provided resource.
*
* @param {Object} resource - The resource object containing the id attribute.
* @param {Object} dt - DataTables instance (not used in this function).
* @param {Event} event - The event object (not used in this function).
*/
const doResourceSelect = (resource, dt, event) => {
this.$emit("select-resource", resource[instancedResource.idAttr]);
};
/**
* Updates the extended_attributes property of the provided resource
*
* @param {Array} additionalFieldValues - Array of objects with
* name and value properties.
* @param {Object} resource - The resource object whose
* extended_attributes property is updated.
*/
const additionalFieldsChanged = (additionalFieldValues, resource) => {
resource.extended_attributes = additionalFieldValues;
};
/**
* Gets the label to display before the filters
*
* @return {String} The label
*/
const getTableFilterFormElementsLabel = () => {
return "";
};
/**
* Gets the array of filters for the table, if required.
* This is a default method that returns an empty array.
* It can be overridden at the resource level if filters are required
*
* @return {Array} The array of filters for the table.
*/
const getTableFilterFormElements = () => {
return [];
};
const refreshTemplateState = () => {
this.refreshTemplate = !this.refreshTemplate;
};
/**
* A function that returns a set of buttons to display in the toolbar, based on the current resource and component.
* The function takes three arguments: the resource, the component and the i18n object.
* It returns an array of buttons. The buttons are a combination of the default buttons and the additional buttons.
* The default buttons are defined in the defaultToolbarButtons function and the additional buttons are defined in the
* additionalToolbarButtons function at the resource level.
*
* @param {Object} resource The current resource
* @param {String} component The current component
* @param {Object} i18n The i18n object
* @return {Array<Object>} An array of buttons
*/
const toolbarButtons = computed(() => {
return (resource, component, i18n, componentData) => {
const defaultButtons = defaultToolbarButtons(resource, i18n);
const additionalButtons = additionalToolbarButtons(
resource,
componentData
);
//FIXME: we need to check that no indexes match between the default buttons and additional buttons
// If we add to the default buttons in future it could mess up indexing
return [
...(defaultButtons[component] || []),
...(additionalButtons[component] || []).filter(
button => Object.keys(button).length > 0
),
].sort((a, b) => a.index - b.index);
};
});
/**
* Generates a new resource object with default values.
*
* This method initializes the `resourceToBeGenerated` property by iterating
* over `resourceAttrs`, assigning default values based on attribute types.
* - If an attribute has a `defaultValue`, it is set accordingly.
* - For text, textarea, and select types, an empty string is assigned.
* - For boolean and checkbox types, a false value is assigned.
* - For additional fields or relationship widgets, an empty array is assigned
* to `extended_attributes` or the attribute name.
* - If none of the above apply, a null value is assigned.
* The `idAttr` of the resource is also initialized to null.
*
* @return {Object} The newly generated resource object with default values.
*/
const newResource = computed(() => {
instancedResource.resourceToBeGenerated =
instancedResource.resourceAttrs.reduce((acc, attr) => {
if (attr.hasOwnProperty("defaultValue")) {
acc[attr.name] = attr.defaultValue;
return acc;
}
if (["text", "textarea", "select"].includes(attr.type)) {
acc[attr.name] = "";
return acc;
}
if (["boolean", "checkbox"].includes(attr.type)) {
acc[attr.name] = false;
return acc;
}
if (
attr.name === "additional_fields" ||
attr.type === "relationshipWidget"
) {
acc[
attr.name === "additional_fields"
? "extended_attributes"
: attr.name
] = [];
return acc;
}
acc[attr.name] = null;
return acc;
}, {});
instancedResource.resourceToBeGenerated[instancedResource.idAttr] =
null;
return instancedResource.resourceToBeGenerated;
});
/**
* Returns the plural form of the resource name.
*
* This method checks if the `resourceName` ends with 's'. If it does not,
* it appends 's' to create the plural form. Otherwise returns `resourceName`.
*
* @return {String} The plural form of the resource name.
*/
const resourceNamePlural = computed(() => {
if (!instancedResource.resourceName.endsWith("s"))
return instancedResource.resourceName + "s";
return instancedResource.resourceName;
});
/**
* Determines if the resource has additional fields.
*
* @return {Boolean} true if the resource has additional fields, false otherwise.
*/
const hasAdditionalFields = computed(() => {
return instancedResource.resourceAttrs.some(
attr => attr.name === "additional_fields"
);
});
return {
...instancedResource,
getFilterValues,
getFilters,
toolbarButtons,
defaultToolbarButtons,
additionalToolbarButtons,
goToResourceAdd,
getFieldGroupings,
created,
getResourceTableUrl,
getResourceShowURL,
goToResourceEdit,
getResource,
goToResourceShow,
goToResourceList,
appendToShow,
doResourceSelect,
doResourceDelete,
additionalFieldsChanged,
getTableFilterFormElementsLabel,
getTableFilterFormElements,
refreshTemplateState,
resourceNamePlural,
hasAdditionalFields,
newResource,
setConfirmationDialog,
setMessage,
setError,
setWarning,
format_date,
patron_to_html,
map_av_dt_filter,
get_lib_from_av,
build_url,
};
}