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) #1232

Closed
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/javascript-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
# Step 3: Install Prettier and ESLint globally
- name: Install Prettier
run: |
npm install -g prettier
npm install -g prettier@3.4.2

# Step 4: Run Prettier to format code
- name: Run prettier
Expand Down
32 changes: 32 additions & 0 deletions web/.mocharc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Mocha configuration file.
*
* This configuration file sets up various options for running Mocha tests.
*
* @property {boolean} diff - Show diff on failure.
* @property {boolean} recursive - Include subdirectories.
* @property {boolean} exit - Force Mocha to quit after tests complete.
* @property {string[]} extension - File extensions to include.
* @property {string} package - Path to the package.json file.
* @property {string} reporter - Reporter to use.
* @property {number} timeout - Test-case timeout in milliseconds.
* @property {string} ui - User interface to use (e.g., BDD, TDD).
* @property {string[]} require - Modules to require before running tests.
* @property {string[]} watch-files - Files to watch for changes.
* @property {string[]} watch-ignore - Files to ignore when watching.
* @property {string[]} spec - Test files to run.
*/
module.exports = {
diff: true,
recursive: true,
exit: true,
extension: ["js"],
package: "./package.json",
reporter: "spec",
timeout: 2000,
ui: "bdd",
require: ["test/setup.js"],
"watch-files": ["test/**/*.js", "static/**/*.js"],
"watch-ignore": ["node_modules", "coverage"],
spec: ["test/**/*.test.js"],
};
14 changes: 9 additions & 5 deletions web/package.json.in
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"sanitize-html": "^2.11.0"
},
"scripts": {
"test": "mocha"
"test": "mocha --config .mocharc.cjs"
},
"repository": {
"type": "git",
Expand All @@ -36,10 +36,14 @@
},
"homepage": "https://github.com/ORNL/DataFed#readme",
"devDependencies": {
"chai": "^4",
"esm": "^3.2.25",
"mocha": "^10.8.2",
"pug": "^3.0.3"
"chai": "5.1.2",
"esm": "3.2.25",
"jsdom": "26.0.0",
"jsdom-global": "3.0.2",
"mocha": "11.0.1",
"prettier": "3.4.2",
"pug": "3.0.3",
"sinon": "19.0.2"
},
"type": "module"
}
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) {
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);
}
}
}
Loading
Loading