Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DLT-1110] Mapped Collection Endpoint Browse (1/4) #1208

Merged
merged 10 commits into from
Jan 14, 2025
26 changes: 26 additions & 0 deletions web/static/components/transfer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { dlgAlert } from "../../dialogs.js";
import { TransferDialogController } from "./transfer-dialog-controller.js";

export class TransferDialog {
constructor() {
this.currentDialog = null;
}

/**
* Show transfer dialog
* @param {number|null} mode - Transfer mode (GET/PUT)
* @param {Array<Object>|null} records - Data records
* @param {Function} callback - Completion callback
*/
show(mode, records, callback) {
try {
this.currentDialog = new TransferDialogController(mode, records, callback);
this.currentDialog.show();
} catch (error) {
console.error("Error showing transfer dialog:", error);
dlgAlert("Error", "Failed to open transfer dialog");
}
}
}

export const transferDialog = new TransferDialog();
41 changes: 41 additions & 0 deletions web/static/components/transfer/transfer-dialog-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { TransferEndpointManager } from "./transfer-endpoint-manager.js";
import { TransferModel } from "../../models/transfer-model.js";
import { TransferUIManager } from "./transfer-ui-manager.js";
import * as dialogs from "../../dialogs.js";
import * as api from "../../api.js";

/**
* @class TransferDialogController
* @classDesc Manages the UI and logic for data transfers
*/
export class TransferDialogController {
/**
* @param {number} mode - Transfer mode (GET/PUT)
* @param {Array<Object>} ids - Records to transfer
* @param {Function} callback - Completion callback
* @param services - The service objects to use for API and dialog operations
* @param {Object} services.dialogs - Dialog service
* @param {Function} services.dialogs.dlgAlert - Alert dialog function
* @param {Object} services.api - API service
*/
constructor(mode, ids, callback, services = { dialogs, api }) {
this.model = new TransferModel(mode, ids);
this.endpointManager = new TransferEndpointManager(this, services);
this.uiManager = new TransferUIManager(this, services);
this.ids = ids;
this.callback = callback;
this.services = services;
}

show() {
try {
this.uiManager.initializeComponents();
this.uiManager.attachMatchesHandler();
this.endpointManager.initialized = true;
this.uiManager.showDialog();
} catch (error) {
console.error("Failed to show transfer dialog:", error);
this.services.dialogs.dlgAlert("Error", "Failed to open transfer dialog");
}
}
}
173 changes: 173 additions & 0 deletions web/static/components/transfer/transfer-endpoint-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { createMatchesHtml } from "./transfer-templates.js";

/**
* @classDesc Manages endpoint operations and state for file transfers
* @class TransferEndpointManager
*/
export class TransferEndpointManager {
#controller;

/**
* Creates a new TransferEndpointManager instance
* @param {Object} controller - The transfer controller instance
* @param services - The service objects to use for API and dialog operations
* @param {Object} services.dialogs - Dialog service
* @param {Function} services.dialogs.dlgAlert - Alert dialog function
* @param {Object} services.api - API service
* @param {Function} services.api.epView - Endpoint view API function
* @param {Function} services.api.epAutocomplete - Endpoint autocomplete API function
*/
constructor(controller, services) {
this.initialized = false;
this.#controller = controller;
this.api = services.api; // Dependency injection
this.dialogs = services.dialogs; // Dependency injection

this.currentEndpoint = null;
this.endpointManagerList = null;
// Search tracking mechanism to prevent race conditions:
// * searchTokenIterator generates unique tokens for each search request
// * currentSearchToken tracks the latest valid search request
// Without this, out-of-order API responses could update the UI with stale data
// Example: User types "abc" then quickly types "xyz"
// - "abc" search starts (token: 1)
// - "xyz" search starts (token: 2)
// - "abc" results return (ignored, token mismatch)
// - "xyz" results return (processed, matching token)
this.currentSearchToken = null;
this.searchTokenIterator = 0;
}

/**
* Performs autocomplete search for endpoints
* @param {string} endpoint - The endpoint search term
* @param {string} searchToken - Token to track current search request
*/
searchEndpointAutocomplete(endpoint, searchToken) {
this.api.epAutocomplete(endpoint, (ok, data) => {
// Prevent race conditions by ignoring responses from outdated searches
// Without this check, rapid typing could cause UI flickering and incorrect results
// as slower API responses return after newer searches
if (searchToken !== this.currentSearchToken) {
AronPerez marked this conversation as resolved.
Show resolved Hide resolved
return;
}

if (ok && data.DATA && data.DATA.length) {
this.endpointManagerList = data.DATA;
// Process endpoints and update UI
data.DATA.forEach((ep) => {
ep.name = ep.canonical_name || ep.id;
});
this.updateMatchesList(data.DATA);
} else {
console.warn("No matches found");
this.endpointManagerList = null;
this.updateMatchesList([]);
if (data.code) {
console.error("Autocomplete error:", data);
this.dialogs.dlgAlert("Globus Error", data.code);
}
}
});
}

/**
* Searches for a specific endpoint
* @param {string} endpoint - The endpoint to search for
* @param {string} searchToken - Token to track current search request
* @returns {Promise|undefined} API response promise if available
*/
searchEndpoint(endpoint, searchToken) {
console.info("Searching for endpoint:", endpoint);

try {
return this.api.epView(endpoint, (ok, data) => {
if (searchToken !== this.currentSearchToken) {
console.warn("Ignoring stale epView response");
return;
}

if (ok && !data.code) {
console.info("Direct endpoint match found:", data);
this.#controller.uiManager.updateEndpoint(data);
this.#controller.uiManager.state.endpointOk = true;
this.#controller.uiManager.updateButtonStates();
} else {
console.warn("No direct match, trying autocomplete");
this.searchEndpointAutocomplete(endpoint, searchToken);
}
});
} catch (error) {
this.dialogs.dlgAlert("Globus Error", error);
}
}

/**
* ------------UPDATE------------
*/

/**
* Updates the list of endpoint matches in the UI
* @param {Array<Object>} [endpoints=[]] - Array of endpoint objects
*/
updateMatchesList(endpoints = []) {
const matches = $("#matches", this.#controller.uiManager.state.frame);
if (!endpoints.length) {
matches.html("<option disabled selected>No Matches</option>");
matches.prop("disabled", true);
return;
}

const html = createMatchesHtml(endpoints);
matches.html(html);
matches.prop("disabled", false);
}

/**
* ------------HANDLERS------------
*/

/**
* Handles path input changes and triggers endpoint search
* @param {string} searchToken - Token to track current search request
*/
handlePathInput(searchToken) {
if (!this.initialized) {
console.warn("Dialog not yet initialized - delaying path input handling");
setTimeout(() => this.handlePathInput(searchToken), 100);
return;
}

// Validate that we're processing the most recent search request
// This prevents wasted API calls and UI updates for abandoned searches
if (searchToken !== this.currentSearchToken) {
console.info("Token mismatch - ignoring stale request");
return;
}

const pathElement = $("#path", this.#controller.uiManager.state.frame);
const path = pathElement?.val()?.trim() || "";

if (!path.length) {
console.info("Empty path - disabling endpoint");
this.endpointManagerList = null;
this.updateMatchesList([]);
this.#controller.uiManager.updateButtonStates();
return;
}

const endpoint = path.split("/")[0];
console.info(
"Extracted endpoint:",
endpoint,
"Current endpoint:",
this.currentEndpoint?.name,
);

if (!this.currentEndpoint || endpoint !== this.currentEndpoint.name) {
console.info("Endpoint changed or not set - searching for new endpoint");
this.#controller.uiManager.updateButtonStates();
this.searchEndpoint(endpoint, searchToken);
}
}
}
118 changes: 118 additions & 0 deletions web/static/components/transfer/transfer-templates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { escapeHTML } from "../../util.js";
import { TransferMode } from "../../models/transfer-model.js";

/**
* @module TransferTemplates
* @description Template strings for transfer dialog components
*/

/**
* Gets mode-specific options template HTML
* @param {TransferMode[keyof TransferMode]} mode - The transfer mode
* @returns {string} Mode-specific options template HTML
*/
export function getModeSpecificOptionsTemplate(mode) {
let responseHTML = "";
if (mode === TransferMode.TT_DATA_GET) {
responseHTML = `<br>File extension override: <input id='ext' type='text'><br>`;
} else if (mode === TransferMode.TT_DATA_PUT) {
responseHTML = `<br><label for='orig_fname'>Download to original filename(s)</label>
<input id='orig_fname' type='checkbox'>`;
}

return responseHTML;
}

/**
* Gets the transfer options template HTML
* @param {TransferMode[keyof TransferMode]} mode - The transfer mode
* @returns {string} Transfer options template HTML
*/
export function getTransferOptionsTemplate(mode) {
return `
<br>Transfer Encryption:&nbsp
<input type='radio' id='encrypt_none' name='encrypt_mode' value='0'>
<label for='encrypt_none'>None</label>&nbsp
<input type='radio' id='encrypt_avail' name='encrypt_mode' value='1' checked/>
<label for='encrypt_avail'>If Available</label>&nbsp
<input type='radio' id='encrypt_req' name='encrypt_mode' value='2'/>
<label for='encrypt_req'>Required</label><br>
${getModeSpecificOptionsTemplate(mode)}
`;
}

/**
* Gets the dialog template HTML
* @param {Object} labels - The labels for dialog elements
* @param {TransferMode[keyof TransferMode]} mode - The transfer mode
* @param {string} labels.record - Record label text
* @param {string} labels.endpoint - Endpoint label text
* @returns {string} The dialog template HTML
*/
export function getDialogTemplate(labels, mode) {
return `
<div class='ui-widget' style='height:95%'>
${labels.record}: <span id='title'></span><br>
<div class='col-flex' style='height:100%'>
<div id='records' class='ui-widget ui-widget-content'
style='flex: 1 1 auto;display:none;height:6em;overflow:auto'>
</div>
<div style='flex:none'><br>
<span>${labels.endpoint} Path:</span>
<div style='display: flex; align-items: flex-start;'>
<textarea class='ui-widget-content' id='path' rows=3
style='width:100%;resize:none;'></textarea>
<button class='btn small' id='browse'
style='margin-left:10px; line-height:1.5; vertical-align: top;'
disabled>Browse</button>
</div>
<br>
<select class='ui-widget-content ui-widget' id='matches'
size='7' style='width: 100%;' disabled>
<option disabled selected>No Matches</option>
</select>
${getTransferOptionsTemplate(mode)}
</div>
</div>
</div>
`;
}

/**
* Creates HTML for endpoint matches
* @param {Array<Object>} endpoints - List of endpoint objects
* @returns {string} Generated HTML for matches
*/
export function createMatchesHtml(endpoints) {
const html = [
`<option disabled selected>${endpoints.length} match${
endpoints.length > 1 ? "es" : ""
}</option>`,
];

endpoints.forEach((ep) => {
html.push(`
<option title="${escapeHTML(ep.description || "(no info)")}">${escapeHTML(
ep.display_name || ep.name,
)}</option>
`);
});

return html.join("");
}

/**
* Formats a record title for display
* @param {Object} item - The record item
* @param {Object} info - Record information
* @returns {string} Formatted HTML string for record title
* @private
*/
export function formatRecordTitle(item, info) {
const titleText =
`${escapeHTML(item.id)}&nbsp&nbsp&nbsp` +
`<span style='display:inline-block;width:9ch'>${escapeHTML(info.info)}</span>` +
`&nbsp${escapeHTML(item.title)}`;

return info.selectable ? titleText : `<span style='color:#808080'>${titleText}</span>`;
}
Loading
Loading