Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
!/modules/web-worker-manager

/plugins/**
!/plugins/analyze
!/plugins/analyze/**
!/plugins/annotations
!/plugins/custom-pages
!/plugins/empaia
Expand Down
24 changes: 24 additions & 0 deletions plugins/analyze/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

Analyze plugin
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation? Awesome!

========================

Purpose
-------
Adds an "Analyze" tab to the AppBar with two actions: a "Run Recent →" anchor that opens a right-side recent-jobs panel, and "Create New App" which creates floating window with new app form.

Files
-----
- `analyzeDropdown.mjs` - registers the tab and wires dropdown items.
- `newAppForm.mjs` - the floating form used by "Create New App".

How to use
----------
- Provide recent jobs by passing `params.recentJobs` or saving `recentJobs` via plugin options.
- Handle job clicks by implementing `onJobClick({ index, label })` on the plugin instance.
- Provide `params.onCreate` to receive form submission data from `NewAppForm`.

Implementation notes
--------------------
- UI behaviors (menu, positioning, hover) are implemented in `SidePanel` (`setMenu`, `showNear`, `scheduleHide`, `cancelHide`) — reuse it for other flyouts.
- `SidePanel.hide()` currently removes the element; consider switching to `display:none` if you need faster show/hide cycles.

189 changes: 189 additions & 0 deletions plugins/analyze/analyzeDropdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Dropdown } from "../../ui/classes/elements/dropdown.mjs";
import { NewAppForm } from "./newAppForm.mjs";
import { SidePanel } from "../../ui/classes/components/sidePanel.mjs";

addPlugin('analyze', class extends XOpatPlugin {
constructor(id, params) {
super(id);
this.params = params || {};
// plugin-level stored recent jobs can be configured via params or saved options
this.recentJobs = this.getOption('recentJobs') || this.params.recentJobs || [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Params are already read from using getOption so avoid touching params directly for this matter.

}

pluginReady() {
const register = () => {

if (!window.USER_INTERFACE || !USER_INTERFACE.AppBar || !USER_INTERFACE.AppBar.menu) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad alignment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, user interface should be already available, if it is not, it is a bug.


// retry shortly if AppBar not ready yet
return setTimeout(register, 50);
}

// safe translation helper: return translated value or fallback when missing
const tOr = (key, fallback) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need this generic 'safe' utility, we should put this function to the CORE api, not here... Why is typeof $?.t === 'function' needed? It should be always defined.

if (typeof $?.t === 'function') {
try {
const translated = $.t(key);
if (translated && translated !== key) return translated;
} catch (e) { /* ignore and fallback */ }
}
return fallback;
};

const title = tOr('analyze.title', 'Analyze');
const tab = USER_INTERFACE.AppBar.addTab(
this.id, // ownerPluginId
title, // title (localized if available)
'fa-magnifying-glass', // icon
[], // body
Dropdown // itemClass so Menu constructs plugin component
);


if (tab) {
const attachToggle = () => {
try {
const btnId = `${tab.parentId}-b-${tab.id}`;
const btnEl = document.getElementById(btnId);
if (!btnEl) return false;
let wrapper = btnEl.closest('.dropdown');
if (!wrapper) {
try {
const newWrapper = tab.create();
const parent = btnEl.parentElement;
if (parent) {
parent.insertBefore(newWrapper, btnEl);
btnEl.remove();
wrapper = newWrapper;
}
} catch (e) {
}
}

if (wrapper) {
const trigger = wrapper.querySelector('[tabindex]') || wrapper;
trigger.addEventListener('click', (e) => {
try {
wrapper.classList.toggle('dropdown-open');
if (!wrapper.classList.contains('dropdown-open')) {
try { tab.hideRecent?.(); } catch(_) {}
}
} catch(_) {}
e.stopPropagation();
});
return true;
}
} catch (e) { console.error('[analyze] attachToggle error', e); }
return false;
};
// Try immediate attach; if DOM not present yet, retry shortly
if (!attachToggle()) setTimeout(attachToggle, 50);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, DOM should be already ready at pluginReady

}

// Configure dropdown items using the Dropdown API
try {
if (tab && typeof tab.addItem === 'function') {
// create the 'recent' section but keep its title empty so no uppercase header is shown
try { tab.addSection({ id: 'recent', title: '' }); } catch (e) {}
// prefer a slightly wider dropdown to match previous styling
try { if (tab) { tab.widthClass = 'w-64'; if (tab._contentEl) tab._contentEl.classList.add('w-64'); } } catch(e) {}

// Only add a single anchor item for Run Recent; the detailed list appears in the SidePanel on hover
tab.addItem({
id: 'run-recent',
section: 'recent',
label: tOr('analyze.runRecent', 'Run Recent') + ' \u2192',
onClick: () => false,
});

// create a reusable SidePanel and attach delegated hover handlers to show it
try {
// let the panel size to its content by default (width: 'auto')
const side = new SidePanel({ id: `${this.id}-recent-panel`, width: 'auto', maxHeight: '70vh' });
const attachHover = () => {
try {
const content = tab._contentEl;
if (!content) return false;

// delegate to the 'run-recent' item inside the dropdown content
content.addEventListener('mouseover', (e) => {
const hit = e.target.closest && e.target.closest('[data-item-id]');
if (hit && hit.dataset && hit.dataset.itemId === 'run-recent') {
try {
// cancel any pending hide so we can reopen immediately
side.cancelHide?.();
const jobs = (this.recentJobs && this.recentJobs.length) ? this.recentJobs : ['Recent Job 1','Recent Job 2','Recent Job 3'];
// use SidePanel helper to build a menu and position the panel next to the anchor
side.setMenu(jobs, (it, idx) => {
try { if (typeof this.onJobClick === 'function') this.onJobClick({ index: idx, label: (typeof it === 'string' ? it : (it && it.label)) }); } catch(_){}
});
side.showNear(hit, { nudge: 1 });
try { tab.hideRecent = () => side.hide(); } catch(_) {}
} catch (err) { console.error('[analyze] show side panel error', err); }
}
});

content.addEventListener('mouseout', (e) => {
const related = e.relatedTarget;
if (!related || !related.closest || !related.closest(`#${side.id}`)) side.scheduleHide();
});
return true;
} catch (e) { console.error('[analyze] attachHover error', e); }
return false;
};
if (!attachHover()) setTimeout(attachHover, 50);
} catch (e) { /* ignore */ }



tab.addItem({
id: 'create-app',
label: tOr('analyze.createApp', 'Create New App'),
onClick: () => {
try {
const form = new NewAppForm({ onSubmit: (data) => {
try { if (this.params.onCreate?.(data) !== false) { alert('Created new app: ' + JSON.stringify(data)); } }
catch (err) { console.error(err); }
}});
const win = form.showFloating({ title: tOr('analyze.createApp', 'Create New App'), width: 420, height: 360 });
if (!win) {
const overlayId = `${this.id}-newapp-overlay`;
USER_INTERFACE.Dialogs.showCustom(overlayId, 'New App', `<div id="${overlayId}-content"></div>`, '', { allowClose: true });
const container = document.getElementById(overlayId)?.querySelector('.card-body');
if (container) form.attachTo(container);
}
} catch (e) { console.error('[analyze] create-app error', e); }
return false;
}
});
}
} catch (e) { console.warn('[analyze] failed to configure dropdown items', e); }
// Close dropdowns when clicking away: attach a document-level click handler once per tab
const attachDocumentCloser = (t) => {
try {
if (!t || t.__analyzeDocCloserAttached) return;
const btnId = `${t.parentId}-b-${t.id}`;
const docHandler = (ev) => {
try {
const openWrappers = Array.from(document.querySelectorAll('.dropdown.dropdown-open'));
openWrappers.forEach((wrapper) => {
const btnEl = document.getElementById(btnId);
if (btnEl && (btnEl === ev.target || btnEl.contains(ev.target))) return;
try { wrapper.classList.remove('dropdown-open'); } catch(_) {}
});
try { t.hideRecent?.(); } catch(_) {}
} catch (_) { /* swallow */ }
};
document.addEventListener('click', docHandler, true);
const keyHandler = (ev) => { if (ev.key === 'Escape') { try { Array.from(document.querySelectorAll('.dropdown.dropdown-open')).forEach(w=>w.classList.remove('dropdown-open')); try { t.hideRecent?.(); } catch(_){} } catch(_){} } };
document.addEventListener('keydown', keyHandler, true);
t.__analyzeDocCloserAttached = true;
} catch (e) { /* ignore */ }
};
try { attachDocumentCloser(tab); } catch(e) { /* ignore */ }
// diagnostic logs removed
};

register();
}
});
10 changes: 10 additions & 0 deletions plugins/analyze/include.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "analyze",
"name": "Analyze dropdown in Main Menu",
"author": "Filip Vrubel",
"version": "1.0.0",
"description": "Plugin for creating and running jobs",
"icon": null,
"includes" : ["newAppForm.mjs", "analyzeDropdown.mjs"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can include only main file, import/export is handled by browser automatically. This will preload the files no matter whether they are needed or not, browser will do it dynamically. Just FYI no need to change.

"permaLoad": true
}
Loading