define([
"jquery", // for semantic UI
"underscore", // for alert template only
"backbone",
"semantic",
"collections/metadata/eml/EMLAttributes",
"common/Utilities",
"text!templates/alert.html",
], ($, _, Backbone, Semantic, EMLAttributes, Utilities, AlertTemplate) => {
/**
* @class AutofillAttributesView
* @classdesc
* @classcategory Views/Metadata
* @screenshot views/metadata/AutofillAttributesView.png
* @augments Backbone.View
*/
// Default timeout for the button to revert to its original state
// after a successful action
const BUTTON_TIMEOUT = 3000;
// Class names used in this view
const CLASS_NAMES = {
view: "autofill-attributes",
buttonContainer: "autofill-attributes__button-container",
tabDynamicContent: "autofill-attributes_tab-dynamic-content",
notificationContainer: "autofill-attributes__notification",
checkboxeContainer: "autofill-attributes__checkbox-list",
copyFromSelect: "autofill-attributes__copy-from-select",
};
// Font awesome icon names
const ICONS = {
warning: "warning-sign",
magic: "magic",
from: "signin",
to: "signout",
onLeft: "on-left",
file: "file",
success: "check",
processing: "spinner",
};
// Prefix to add ato all ICONS
const ICON_PREFIX = "icon-";
// Classnames the come from bootstrap used here
const BOOTSTRAP_CLASS_NAMES = {
warning: "warning",
info: "alert-info",
button: "btn",
buttonPrimary: "btn-primary",
active: "active",
tabContent: "tab-content",
navPills: "nav-pills",
navPill: "nav-pill",
tabPane: "tab-pane",
well: "well",
checkbox: "checkbox",
};
const AutofillAttributesView = Backbone.View.extend(
/** @lends AutofillAttributesView.prototype */ {
/**
* The className to add to the view container
* @type {string}
*/
className: CLASS_NAMES.view,
/**
* A list of file formats that can be auto-filled with attribute
* information
* @type {string[]}
*/
fillableFormats: ["text/csv"],
/**
* Settings for the semantic UI popup used to indicate that an attribute
* will be removed when the remove button is clicked
* @type {object}
*/
buttonTooltipSettings: {
content: "",
position: "top center",
variation: `${Semantic.CLASS_NAMES.variations.inverted} ${Semantic.CLASS_NAMES.variations.mini}`,
delay: {
show: 500,
hide: 20,
},
exclusive: true,
},
/**
* Generates the HTML template for the AutofillAttributesView.
* @returns {DocumentFragment} The HTML template content for the
* AutofillAttributesView.
*/
template() {
const template = document.createElement("template");
const BC = BOOTSTRAP_CLASS_NAMES;
template.innerHTML = `<div><ul class="${BC.navPills}"></ul><div class="${BC.tabContent}"></div></div>`;
return template.content.querySelector("div");
},
/**
* Creates a Bootstrap-styled navigation tab element.
* @param {string} target - The ID of the target tab content to be
* displayed when this tab is selected.
* @param {string} icon - The CSS class name(s) for the icon to be
* displayed on the tab.
* @param {string} text - The text to be displayed on the tab.
* @returns {HTMLLIElement} - The `<li>` element representing the
* navigation tab.
*/
actionTabTemplate(target, icon, text) {
// Create the template
const template = document.createElement("template");
const BC = BOOTSTRAP_CLASS_NAMES;
template.innerHTML = `
<li class="${BC.navPill}">
<a data-toggle="tab" data-target="#${target}">
<i class="${icon} ${ICONS.onLeft}"></i>${text}
</a>
</li>`;
return template.content.querySelector("li");
},
/**
* Creates an action panel template as a DOM element.
* @param {string} id - The unique identifier for the action panel.
* @param {string} text - The text content to display in the panel.
* @returns {HTMLDivElement} The generated action panel as a `div`
* element.
*/
actionPanelTemplate(id, text) {
const template = document.createElement("template");
const CN = CLASS_NAMES;
const BC = BOOTSTRAP_CLASS_NAMES;
template.innerHTML = `
<div class="${BC.tabPane} ${BC.well}" id="${id}">
<p>${text}</p>
<div class="${CN.tabDynamicContent}"></div>
<div class="${CN.buttonContainer}">
<button class="${BC.button} ${BC.buttonPrimary}">
<i class="${ICONS.onLeft}"></i><span></span>
</button>
</div>
</div>`;
return template.content.querySelector("div");
},
/**
* The template for the alert message
* @type {UnderscoreTemplate}
*/
alertTemplate: _.template(AlertTemplate),
/**
* The configuration options and context for an auto-fill action available
* in the view.
* @typedef {object} Action
* @property {string} label - The label for the action, displayed in the
* UI.
* @property {string} text - A description of the action's purpose.
* @property {string} icon - The CSS class for the icon representing the
* action.
* @property {string} renderMethod - The name of the method that renders
* the action's UI.
* @property {string} buttonMethod - The name of the method that handles
* the button's click event.
* @property {HTMLElement|null} tabEl - The DOM element for the action's
* tab (initialized as null).
* @property {HTMLElement|null} actionEl - The DOM element for the
* action's panel (initialized as null).
* @property {ButtonState} buttonStates - An object containing the
* different states of the button, each with its own properties.
*/
/**
* Properties of a button during a specific state.
* @typedef {object} ButtonState
* @property {string} message - The message to display on the button.
* @property {string} icon - The Font Awesome icon name to display on the button.
* @property {string} tooltip - The tooltip text to display when hovering over the button.
* @property {boolean} active - Indicates whether the button is active or not.
* @property {number} [timeout] - The timeout duration in milliseconds for reverting the button state.
* @property {ButtonState} [afterTimeout] - The state to revert to after a timeout.
*/
/**
* The configuration options for the actions available in the view.
* @type {object}
* @property {Action} fillFromFile - The configuration for the "Fill from
* file" action.
* @property {Action} copyFrom - The configuration for the "Copy from"
* action.
* @property {Action} copyTo - The configuration for the "Copy to" action.
*/
actionConfig: {
fillFromFile: {
label: "Fill from file",
text:
"Use the information provided in your tabular data file to fill " +
"in the attribute names.",
icon: ICONS.file,
renderMethod: "renderFillFromFile",
buttonMethod: "handleFill",
tabEl: null,
actionEl: null,
buttonStates: {
default: {
message: "Fill attributes from file",
icon: ICONS.magic,
tooltip: "Click to fill attributes from the uploaded file",
active: true,
},
loading: {
message: "Please wait...",
icon: ICONS.processing,
tooltip: "Fetching file contents...",
active: true,
},
success: {
message: "Filled!",
icon: ICONS.success,
tooltip: "Attribute names filled successfully",
active: true,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
error: {
message: "Failed to fill",
icon: ICONS.warning,
tooltip: "An error occurred while fetching the file.",
active: false,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
unsupportedFormat: (thisFormat, allowedFormats) => ({
message: `Cannot fill attributes from ${thisFormat} files`,
icon: ICONS.warning,
tooltip: `Only ${allowedFormats} files are supported for autofill at this time.`,
active: false,
}),
},
},
copyFrom: {
label: "Copy from...",
text:
"Copy all attribute information from another file to this " +
"one! Select a source file below",
icon: ICONS.from,
renderMethod: "renderCopyFrom",
buttonMethod: "handleCopyFrom",
tabEl: null,
actionEl: null,
buttonStates: {
default: {
message: "Copy attributes from file",
icon: ICONS.magic,
tooltip: "Select a file to copy attributes from",
active: false,
},
selection: (selectedFile) => ({
message: `Copy attributes from <b>${selectedFile}</b>`,
icon: ICONS.magic,
tooltip: `All attributes will be copied from ${selectedFile} to this file`,
active: true,
}),
success: {
message: "Attributes copied!",
icon: ICONS.success,
tooltip: "All attributes copied successfully",
active: true,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
error: {
message: "Failed to copy",
icon: ICONS.warning,
tooltip: "An error occurred while copying attributes.",
active: false,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
cannotCopy: {
message: "Cannot copy attributes from files",
icon: ICONS.warning,
tooltip:
"Check that there are other files with valid attributes to copy from.",
active: false,
},
},
},
copyTo: {
label: "Copy to...",
text:
"Copy all attribute information from this file to other files in the " +
"package! Select target files below",
icon: ICONS.to,
renderMethod: "renderCopyTo",
buttonMethod: "handleCopyTo",
tabEl: null,
actionEl: null,
buttonStates: {
default: {
message: "Copy attributes to files",
icon: ICONS.magic,
tooltip:
"Select at least one file to copy this file's attributes to",
active: false,
},
selection: (checkedValues) => {
const numChecked = checkedValues.length;
const fileNoun = numChecked > 1 ? "files" : "file";
return {
message: `Copy attributes to <b>${numChecked}</b> ${fileNoun}`,
icon: ICONS.magic,
tooltip: `Click to copy attributes to the following ${fileNoun}: ${checkedValues.join(
", ",
)}`,
active: true,
};
},
success: {
message: "Attributes copied!",
icon: ICONS.success,
tooltip: "All attributes copied successfully",
active: true,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
error: {
message: "Failed to copy",
icon: ICONS.warning,
tooltip:
"Check that this file has valid attributes and that there are other files to copy to.",
active: false,
timeout: BUTTON_TIMEOUT,
afterTimeout: "default",
},
},
},
},
/**
* The view will populate actions with a cloned copy of the action
* options. It will be modified to add references to the elements created
* for each action. See {@link actionConfig} for the options.
* @type {object}
*/
actions: {},
/**
* Records any errors that occur during the autofill process. This object
* is used to store error messages and other relevant information.
* @type {object}
*/
errors: {},
/** @inheritdoc */
events() {
const events = {};
events[`change .${CLASS_NAMES.copyFromSelect}`] =
"handleCopyFromSelectChange";
events[`change .${CLASS_NAMES.checkboxeContainer}`] =
"handleCopyToCheckboxChange";
events[`click .${CLASS_NAMES.buttonContainer} button`] =
"handleActionButtonClick";
return events;
},
/**
* Creates a new AutofillAttributesView
* @param {object} options - A literal object with options to pass to the
* view
* @param {EMLAttributes} options.collection - The collection of
* EMLAttribute models to display
* @param {EMLEntity} options.parentModel - The entity model to which
* these attributes belong
* @param {boolean} [options.isNew] - Set to true if this is a new
* attribute
*/
initialize(options = {}) {
this.collection = options.collection || new EMLAttributes();
this.parentModel = options.parentModel;
// Prefix all the icons
if (!Object.values(ICONS)[0].startsWith(ICON_PREFIX)) {
Object.keys(ICONS).forEach((key) => {
ICONS[key] = `${ICON_PREFIX}${ICONS[key]}`;
});
}
// Do the same for the icons in the actions objects' buttonStates
Object.keys(this.actionConfig).forEach((action) => {
const actionObj = this.actionConfig[action];
// Add the prefix to the icon
if (actionObj.icon && !actionObj.icon.startsWith(ICON_PREFIX)) {
actionObj.icon = `${ICON_PREFIX}${actionObj.icon}`;
}
Object.keys(actionObj.buttonStates).forEach((state) => {
const stateObj = actionObj.buttonStates[state];
if (stateObj.icon && !stateObj.icon.startsWith(ICON_PREFIX)) {
stateObj.icon = `${ICON_PREFIX}${stateObj.icon}`;
}
});
});
},
/** @inheritdoc */
render() {
const BC = BOOTSTRAP_CLASS_NAMES;
// Render the template
this.el.innerHTML = "";
this.el.append(this.template());
this.els = {
actionTabsContainer: this.el.querySelector(`.${BC.navPills}`),
actionPanelsContainer: this.el.querySelector(`.${BC.tabContent}`),
};
this.renderActions();
this.stopListening(this.parentModel.collection, "update");
this.listenTo(
this.parentModel.collection,
"update",
this.handleEntityUpdate,
);
return this;
},
/**
* Handles the update of entity panels by re-rendering all non-active panels.
* This method identifies the currently active panel and iterates over the
* available actions to re-render the panels that are not active.
*/
handleEntityUpdate() {
// Get the active panel and re-render the other two
const activePanel = this.els.actionPanelsContainer.querySelector(
`.${BOOTSTRAP_CLASS_NAMES.active}`,
);
const activeId = activePanel.id;
// Iterate over the actions and re-render the non-active ones
Object.keys(this.actions).forEach((action) => {
if (action !== activeId) {
const actionObj = this.actions[action];
const renderMethod = this[actionObj.renderMethod];
if (typeof renderMethod === "function") {
renderMethod.call(this, actionObj);
}
}
});
},
/**
* Renders all actions defined in the actionConfig object. This method
* creates tabs and panels for each action and sets up event listeners.
*/
renderActions() {
// Deep clone actionConfig to actions. Otherwise the changes may persist
// in other instances of this view!
this.actions = {};
Object.entries(this.actionConfig).forEach((action, i) => {
const name = action[0];
const options = action[1];
this.actions[name] = { ...options };
const activate = i === 0;
this.renderAction(name, activate);
}, this);
},
/**
* Renders an action tab and panel in the view, and sets up event
* listeners for the action's button.
* @param {string} name - The name of the action to render. Must match a
* key in the actionConfig object.
* @param {boolean} [activate] - If true, the action will be set as the
* active tab.
*/
renderAction(name, activate = false) {
const BC = BOOTSTRAP_CLASS_NAMES;
const action = this.actions[name];
action.id = `${this.cid}-${name}`;
// Create the pill tab & action contents
action.panelEl = this.actionPanelTemplate(
action.id,
action.text,
action.buttonIcon,
action.buttonText,
);
action.tabEl = this.actionTabTemplate(
action.id,
action.icon,
action.label,
);
action.dynamicContainer = action.panelEl.querySelector(
`.${CLASS_NAMES.tabDynamicContent}`,
);
action.button = action.panelEl.querySelector("button");
// Add the action name to the button
action.button.dataset.action = name;
// Set the button to the default state
this.updateButton(action.button, action.buttonStates.default);
// Append to view
this.els.actionTabsContainer.append(action.tabEl);
this.els.actionPanelsContainer.append(action.panelEl);
if (activate) {
action.tabEl.classList.add(BC.active);
action.panelEl.classList.add(BC.active);
}
const renderMethod = this[action.renderMethod];
if (typeof renderMethod === "function") {
renderMethod.call(this, action);
}
},
/**
* Performs the action associated with the clicked button. This method
* retrieves the action name from the button's data attribute and invokes
* the corresponding method to handle the action.
* @param {event} event - The event object containing the action name.
*/
handleActionButtonClick(event) {
const button = event.currentTarget;
const actionName = button.dataset.action;
const action = this.actions[actionName];
if (!action) return;
const buttonMethod = this[action.buttonMethod];
if (action && typeof buttonMethod === "function") {
buttonMethod.call(this, action);
}
},
/**
* Renders the "Copy to" action, which allows the user to copy attributes
* from the current file to other files in the package.
* @param {Action} action - The action object containing configuration for
* the "Copy to" action.
*/
renderCopyTo(action) {
const canCopy = this.canCopyTo();
if (canCopy !== true) {
this.showCantCopyTo(canCopy);
return;
}
const BC = BOOTSTRAP_CLASS_NAMES;
const container = action.dynamicContainer;
const fileNames = this.getOtherFileNames();
// Generate checkboxes
const checkboxes = fileNames.map((name) => {
const id = `${this.cid}-copy-to-${name}`;
return `<label class="${BC.checkbox}" html-for="${id}">
<input type="checkbox" value="${name}" id="${id}"/>
${name}
</label>`;
});
// Set the container's innerHTML
container.innerHTML = `<div class="${CLASS_NAMES.checkboxeContainer}">${checkboxes.join("")}</div>`;
// Start the button inactive
this.updateButton(action.button, action.buttonStates.default);
// Warn that this will overwrite existing attributes
this.showNotification(
"copyTo",
"This will overwrite any existing attributes in the selected files.",
[BOOTSTRAP_CLASS_NAMES.info],
false,
);
},
/**
* Handles the change event for checkboxes in the "Copy To" action.
* Updates the button state based on the number of selected checkboxes.
* @param {Event} event - The change event triggered by a checkbox.
*/
handleCopyToCheckboxChange(event) {
if (
event.target.tagName === "INPUT" &&
event.target.type === "checkbox"
) {
const action = this.actions.copyTo;
const container = action.dynamicContainer;
// Get all currently checked checkboxes
const checkedValues = this.getCheckedValues(container);
// Update the button state based on the number of selected items
if (checkedValues.length > 0 && this.canCopyTo() === true) {
this.updateButton(
action.button,
action.buttonStates.selection(checkedValues),
);
} else {
this.updateButton(action.button, action.buttonStates.default);
}
}
},
/**
* Checks whether attributes can be copied to other files.
* @returns {"no attributes"|"invalid attributes"|"no other files"|true}
* - "no attributes" if there are no attributes to copy
* - "invalid attributes" if the attributes are invalid
* - "no other files" if there are no other files to copy to
* - true if all checks pass
*/
canCopyTo() {
const attributes = this.collection;
// There must be attributes to copy
if (
!attributes ||
attributes.length === 0 ||
!attributes.hasNonEmptyAttributes()
) {
return "no attributes";
}
// All the attributes must be valid before copying
if (!attributes.isValid(attributes)) {
return "invalid attributes";
}
// There must be other files to copy to
const otherFileNames = this.getOtherFileNames();
if (otherFileNames.length === 0) {
return "no other files";
}
return true;
},
/**
* Displays a warning message and updates the UI when attributes cannot be
* copied to other files.
* @param {"no attributes"|"invalid attributes"|"no other files"} reason -
* The reason why attributes cannot be copied.
*/
showCantCopyTo(reason) {
const action = this.actions.copyTo;
let { message } = action.buttonStates.error;
let { tooltip } = action.buttonStates.error;
if (reason === "no attributes") {
message = "Add at least one attribute to this file before copying";
tooltip = "One or more valid attributes are required to copy.";
} else if (reason === "invalid attributes") {
message =
"All attributes must be valid before copying to other files";
tooltip =
"Please fix any invalid attributes in this file before copying";
} else if (reason === "no other files") {
message =
"No other files to copy attributes to. Please add more files";
tooltip = "At least one other file is required to copy attributes";
}
this.updateButton(action.button, {
...action.buttonStates.error,
message,
tooltip,
});
this.showNotification("copyTo", message, [
BOOTSTRAP_CLASS_NAMES.warning,
]);
},
/**
* Retrieves the values of all checked checkboxes within a specified
* container.
* @param {HTMLElement} container - The container element to search for
* checked checkboxes.
* @returns {string[]} An array of values from the checked checkboxes.
*/
getCheckedValues(container) {
// Find all checked checkboxes within the container
const checkedCheckboxes = container.querySelectorAll(
"input[type='checkbox']:checked",
);
// Map their values into an array
return Array.from(checkedCheckboxes).map((checkbox) => checkbox.value);
},
/**
* Handles the "Copy To" action for copying attributes from the current
* entity to selected entities within the collection. This function
* retrieves the selected entities, copies the attributes using the
* `copyAttributeList` method, and provides feedback to the user via
* button updates.
*/
handleCopyTo() {
const action = this.actions.copyTo;
const selectedValues = this.getCheckedValues(action.dynamicContainer);
const thisEntity = this.parentModel;
const entities = thisEntity.collection;
const selectedEntities = entities.filter((entity) =>
selectedValues.includes(entity.getFileName()),
);
try {
entities.copyAttributeList(thisEntity, selectedEntities, true);
this.updateButton(action.button, action.buttonStates.success);
} catch (error) {
// Get the error type. and call if needed. Include errors
// message in tooltip.
this.errors.copyTo = error;
this.updateButton(action.button, action.buttonStates.error);
}
},
/**
* Checks whether attributes can be copied from other files.
* @returns {"no other files"|"no valid attributes"|true}
* - "no other files" if there are no files to copy from
* - "no valid attributes" if no valid attributes exist in other files
* - true if all checks pass
*/
canCopyFrom() {
// There must be other files with attributes to copy from
const otherFileNames = this.getOtherFileNames();
const entities = this.parentModel.collection;
if (otherFileNames.length === 0) {
return "no other files";
}
if (entities.length === 0 || !entities.hasNonEmptyEntity()) {
return "no other files";
}
if (entities.getEntitiesWithValidAttributes().length === 0) {
return "no valid attributes";
}
return true;
},
/**
* Renders the "Copy from" action, allowing the user to copy attributes
* from another file to the current file.
* @param {Action} action - The action object containing configuration for
* the "Copy from" action.
*/
renderCopyFrom(action) {
const actionRef = action;
const canCopy = this.canCopyFrom();
if (canCopy !== true) {
this.showCantCopyFrom(canCopy);
return;
}
const entities = this.getOtherEntities();
const id = `${this.cid}-copy-from`;
const select = document.createElement("select");
select.id = id;
select.classList.add(CLASS_NAMES.copyFromSelect);
// Add the default option
const option = document.createElement("option");
option.value = "";
option.textContent = "Select a file";
select.append(option);
// Add an option for each file
entities.forEach((entity) => {
const opt = this.createOption(entity);
select.append(opt);
}, this);
actionRef.dynamicContainer.innerHTML = "";
actionRef.dynamicContainer.append(select);
actionRef.select = select;
const { button } = action;
// Deactivate the button until a file is selected
this.updateButton(button, action.buttonStates.default);
// Warn that this will overwrite existing attributes
this.showNotification(
"copyFrom",
"This will overwrite any existing attributes in this file.",
[BOOTSTRAP_CLASS_NAMES.info],
false,
);
},
/**
* Handles the change event for the "Copy from" select element. This
* function updates the button state based on the selected file and
* disables the button if no valid attributes are available for copying.
*/
handleCopyFromSelectChange() {
const { select, button } = this.actions.copyFrom;
const selectedFile = select.options[select.selectedIndex].textContent;
if (selectedFile) {
this.updateButton(
button,
this.actions.copyFrom.buttonStates.selection(selectedFile),
);
} else {
this.updateButton(button, this.actions.copyFrom.buttonStates.default);
}
},
/**
* Displays a message and disables the button when attributes cannot be
* copied.
* @param {string} reason - The reason why attributes cannot be copied.
*/
showCantCopyFrom(reason) {
const action = this.actions.copyFrom;
let message = "Cannot copy attributes from files";
let tooltip =
"Check that there are other files with valid attributes to copy from";
if (reason === "no other files") {
message = "Add at least one other file to copy attributes from";
tooltip = "One or more valid files are required to copy attributes";
} else if (reason === "no valid attributes") {
message =
"All attributes in the selected file must be valid before copying";
tooltip = "One or more invalid attributes are present.";
}
this.updateButton(action.button, {
...action.buttonStates.cannotCopy,
message,
tooltip,
});
this.showNotification("copyFrom", message, [
BOOTSTRAP_CLASS_NAMES.warning,
]);
},
/**
* Creates an option element for a file in the "Copy from" dropdown.
* @param {object} entity - The entity model representing the file.
* @returns {HTMLOptionElement} The created option element.
*/
createOption(entity) {
const name = entity.getFileName() || entity.getId();
const modelId = entity.cid;
const isValid = entity.get("attributeList").isValid();
const isNonEmpty = entity.get("attributeList").hasNonEmptyAttributes();
const opt = document.createElement("option");
opt.value = modelId;
opt.textContent = `${name}`;
if (!isValid || !isNonEmpty) {
let message = "Cannot copy attributes from this file";
if (!isValid) {
opt.textContent += " (INVALID ATTRIBUTES)";
message = "This file has invalid attributes";
} else if (!isNonEmpty) {
opt.textContent += " (NO ATTRIBUTES TO COPY)";
message = "This file has no attributes";
}
opt.disabled = true;
opt.setAttribute("data-content", message);
}
return opt;
},
/**
* Handles the "Copy From" action for copying attributes from a selected
* file to the current file.
* @param {Action} action - The action object containing configuration for
* the "Copy from" action.
*/
handleCopyFrom(action) {
const { select } = action;
// - get select value
// - get matching entity model
// - use copy attributes method in collection to copy here update button
// to indicate success
const selectedValue = select.options[select.selectedIndex].value;
const selectedEntity = this.parentModel.collection.get(selectedValue);
const thisEntity = this.parentModel;
const entities = thisEntity.collection;
try {
entities.copyAttributeList(selectedEntity, [thisEntity], true);
this.updateButton(action.button, action.buttonStates.success);
} catch (error) {
this.errors.copyFrom = error;
this.updateButton(action.button, action.buttonStates.error);
}
},
/**
* Retrieves the names of other files in the collection.
* @returns {string[]} An array of file names.
*/
getOtherFileNames() {
return this.getOtherEntities().map(
(entity) =>
entity.get("entityName") || entity.get("physicalObjectName"),
);
},
/**
* Retrieves the entities representing other files in the collection.
* @returns {object[]} An array of entity models.
*/
getOtherEntities() {
const entities = this.parentModel.collection;
const thisEntity = this.parentModel;
return entities.filter((entity) => entity !== thisEntity);
},
/**
* Checks whether the current file format is supported for the "Fill from
* file" action.
* @returns {boolean} True if the format is supported, false otherwise.
*/
isFillable() {
const { parentModel } = this;
this.formatGuess =
parentModel.get("dataONEObject")?.get("formatId") ||
parentModel.get("entityType");
return this.fillableFormats.includes(this.formatGuess);
},
/**
* Renders the "Fill from file" action, allowing the user to autofill
* attributes from the uploaded file.
* @param {Action} action - The action object containing configuration for
* the "Fill from file" action.
*/
renderFillFromFile(action) {
if (!this.isFillable()) {
this.showWrongFormat();
} else {
this.updateButton(action.button, action.buttonStates.default);
}
},
/**
* Displays a notification when the file format is not supported for
* autofill.
*/
showWrongFormat() {
if (!this.formatGuess) this.isFillable();
const { formatGuess } = this;
const thisFile = formatGuess
? `a ${formatGuess} file`
: "this filetype";
const allowedFormats = this.fillableFormats.join(", ");
const message = `Cannot fill attributes from ${thisFile}.
Only ${allowedFormats} files are supported for autofill at this time.`;
this.showNotification(
"fillFromFile",
message.replace("\n", ""),
[BOOTSTRAP_CLASS_NAMES.warning],
true,
);
const action = this.actions.fillFromFile;
this.updateButton(
action.button,
action.buttonStates.unsupportedFormat(
formatGuess,
this.fillableFormats.join(", "),
),
);
},
/**
* Handles the "Fill from file" action by determining whether to use a
* File object or fetch the file contents.
*/
handleFill() {
const d1Object = this.parentModel.get("dataONEObject");
if (!d1Object) return;
const file = d1Object.get("uploadFile");
try {
if (!file) {
this.handleFillViaFetch();
} else {
this.handleFillViaFile(file);
}
} catch (error) {
this.errors.fillFromFile = error;
const action = this.actions.fillFromFile;
this.updateButton(action.button, action.buttonStates.error);
}
},
/**
* Handles the "Fill from file" action using a File object.
* @param {File} file - A File object to fill from.
*/
handleFillViaFile(file) {
const view = this;
Utilities.readSlice(file, this, (event) => {
if (event.target.readyState !== FileReader.DONE) {
return;
}
view.tryParseAndFillAttributeNames.bind(view)(event.target.result);
});
},
/**
* Handles the "Fill from file" action by fetching the file contents.
*/
handleFillViaFetch() {
const view = this;
const objServiceUrl = MetacatUI.appModel.get("objectServiceUrl");
const fileId = this.parentModel.get("dataONEObject").get("id");
const url = `${objServiceUrl}${encodeURIComponent(fileId)}`;
this.updateButton(
view.actions.fillFromFile.button,
view.actions.fillFromFile.buttonStates.loading,
);
fetch(url, MetacatUI.appUserModel.createFetchSettings())
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.text();
})
.then(view.tryParseAndFillAttributeNames.bind(this))
.catch((e) => {
this.errors.fetch = e;
this.updateButton(
view.actions.fillFromFile.button,
view.actions.fillFromFile.buttonStates.error,
);
});
},
/**
* Attempts to parse the header of a file and fill attribute names.
* @param {string} content - The content of the file to parse.
*/
tryParseAndFillAttributeNames(content) {
const names = Utilities.tryParseCSVHeader(content);
if (!names?.length) {
this.updateButton(this.actions.fillFromFile.button, {
...this.actions.fillFromFile.buttonStates.error,
message: "No attribute names found in file",
});
return;
}
this.listenToOnce(this.collection, "namesUpdated", () => {
this.updateButton(
this.actions.fillFromFile.button,
this.actions.fillFromFile.buttonStates.success,
);
this.render();
this.showNotification(
"fillFromFile",
"Attribute names filled successfully",
[BOOTSTRAP_CLASS_NAMES.success],
);
});
this.collection.updateNames(names, this.parentModel);
},
/**
* Updates a button to show a new status.
* @param {HTMLElement} buttonEl - The button to update.
* @param {ButtonState} state - The new state to set for the button.
*/
updateButton(buttonEl, state) {
const button = buttonEl;
// Store the default state if it doesn't already exist
if (!button.dataset.defaultState) {
button.dataset.defaultState = JSON.stringify(state);
}
const {
message,
icon,
tooltip,
active = true,
timeout,
afterTimeout,
} = state;
if (message && message !== "") {
const messageEl = button.querySelector("span");
messageEl.innerHTML = message;
}
if (icon) {
const iconEl = button.querySelector("i");
// Find the icon classes
const iconClasses = Array.from(iconEl.classList).filter(
(cls) => cls.startsWith(ICON_PREFIX) && cls !== ICONS.onLeft,
);
iconEl.classList.remove(...iconClasses);
iconEl.classList.add(icon);
}
if (tooltip) {
const tooltipSettings = {
content: tooltip,
...this.buttonTooltipSettings,
};
button.setAttribute("data-content", tooltip);
$(button).popup(tooltipSettings);
} else if (tooltip === "") {
button.removeAttribute("data-content");
$(button).popup("destroy");
}
const buttonRef = button;
if (active) buttonRef.disabled = false;
else buttonRef.disabled = true;
// Handle timeout and afterTimeout
if (timeout > 0 && afterTimeout) {
setTimeout(() => {
let nextState = afterTimeout;
if (afterTimeout === "default") {
nextState = JSON.parse(button.dataset.defaultState);
}
this.updateButton(button, nextState);
}, timeout);
}
},
/**
* Displays a notification message in the UI.
* @param {string} actionName - The key of the action to show the
* notification in.
* @param {string} msg - The message to show in the notification.
* @param {string[]} classes - The classes to add to the notification.
* @param {boolean} emptyContainer - Set to true to remove any existing
* notifications in the container.
*/
showNotification(actionName, msg, classes, emptyContainer = true) {
const action = this.actions[actionName];
const { dynamicContainer } = action;
const notification = this.alertTemplate({
msg,
classes,
includeEmail: false,
remove: false,
});
// Removing any existing notification
const existingNotification = action.notification;
if (existingNotification) existingNotification.remove();
if (emptyContainer) dynamicContainer.innerHTML = "";
// Add the notification to the start of the container
dynamicContainer.insertAdjacentHTML("afterbegin", notification);
action.notification = dynamicContainer.firstChild;
},
/**
* Cleans up the view by removing all event listeners.
*/
onClose() {
// remove all listeners
this.stopListening();
},
},
);
return AutofillAttributesView;
});