Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
be4cdd8
Add asset copy functionality with tabbed interface in asset creation …
joshuaunity May 22, 2026
f5aa38b
feat: Enhance asset copy tab with infinite scroll and loading indicators
joshuaunity May 22, 2026
d2c94fe
Update flexmeasures/ui/templates/assets/asset_new.html
joshuaunity May 25, 2026
609425b
feat: Add asset type filtering to asset API and update UI parameters
joshuaunity May 25, 2026
3336c0c
chore: add changelog entry
joshuaunity May 25, 2026
a1c3380
ref: make card title clickable
joshuaunity May 25, 2026
28329b9
feat: Set external_id to None in copy_asset to prevent unique constra…
joshuaunity May 25, 2026
4b472a4
chore: code linting
joshuaunity May 25, 2026
339f8ae
feat: Remove unused parentAssetId variable and related logic from ass…
joshuaunity May 25, 2026
967b378
Merge branch 'main' into feat/asset-create-copy-tab-ui-flow
joshuaunity May 29, 2026
5f4838f
feat: Update asset type handling and add AssetTypeIdField for better …
joshuaunity May 29, 2026
6573504
chore: code formatting
joshuaunity May 29, 2026
e5749e3
feat: Add parent asset ID handling for asset copy functionality
joshuaunity Jun 1, 2026
14b2902
feat: Set external_id to None when copying assets to avoid unique con…
joshuaunity Jun 1, 2026
b407e22
Update flexmeasures/ui/templates/assets/asset_new.html
joshuaunity Jun 2, 2026
5ce1008
fix: fixed issue were public assets are always added
joshuaunity Jun 2, 2026
5f3bdc2
feat: enhance asset copy UI with pagination and improved loading state
joshuaunity Jun 2, 2026
8a94c30
feat: update asset copy functionality to handle None values for accou…
joshuaunity Jun 12, 2026
26a042f
chore; set search input to search template keyword by default
joshuaunity Jun 12, 2026
e022ca4
Merge branch 'main' into feat/asset-create-copy-tab-ui-flow
nhoening Jul 1, 2026
75f242f
use all_accessible param to look up assets, show 15 per page
nhoening Jul 1, 2026
80b993f
add a doc section on how to create a child asset
nhoening Jul 1, 2026
25c75a1
no pre-fill of search term, let's do that in the next PR
nhoening Jul 1, 2026
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
332 changes: 330 additions & 2 deletions flexmeasures/ui/templates/assets/asset_new.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,28 @@
{% block divs %}

<div class="container-fluid">

<h3>Creating a new asset {% if parent_asset_name %} under asset <a href="/assets/{{parent_asset_id}}">{{ parent_asset_name }}</a> {% endif %}</h3>


<!-- Tab navigation -->
<ul class="nav nav-tabs mb-3" id="newAssetTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="create-new-tab" data-bs-toggle="tab"
data-bs-target="#create-new" type="button" role="tab"
aria-controls="create-new" aria-selected="true">Create new</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="copy-asset-tab" data-bs-toggle="tab"
data-bs-target="#copy-asset" type="button" role="tab"
aria-controls="copy-asset" aria-selected="false">Copy an existing</button>
Comment thread
joshuaunity marked this conversation as resolved.
Outdated
</li>
</ul>

<div class="tab-content" id="newAssetTabContent">

<!-- Tab 1: Create new asset form -->
<div class="tab-pane fade show active" id="create-new" role="tabpanel" aria-labelledby="create-new-tab">

<form class="new-asset-form form-horizontal" method="POST" action="/assets/create">
{{ asset_form.csrf_token }}
{{ asset_form.hidden_tag() }}
Expand Down Expand Up @@ -108,6 +127,52 @@ <h3>Location</h3>
</div>
</fieldset>
</form>

</div><!-- end #create-new tab pane -->

<!-- Tab 2: Copy an existing asset -->
<div class="tab-pane fade" id="copy-asset" role="tabpanel" aria-labelledby="copy-asset-tab">

<div class="row mb-3 mt-3">
<div class="col-md-5">
<input type="text" id="copySearchInput" class="form-control"
placeholder="Search assets…">
</div>
Comment thread
Flix6x marked this conversation as resolved.
<div class="col-md-3">
<select id="copyAssetTypeSelect" class="form-select">
<option value="">All types</option>
{% for at in asset_types %}
<option value="{{ at.id }}">{{ at.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="copyIncludePublic" checked>
<label class="form-check-label" for="copyIncludePublic">Include public assets</label>
</div>
</div>
</div>

<div id="copyAssetsSpinner" class="justify-content-center my-4 d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading…</span>
</div>
</div>

<div id="copyAssetsResults" class="row row-cols-1 row-cols-sm-2 row-cols-lg-4 g-1 my-2"></div>
<div id="copyAssetsStatus" class="small text-muted my-1"></div>
<div id="copyAssetsBottomSpinner" class="justify-content-center my-2 d-none">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading more…</span>
</div>
</div>
<div id="copyAssetsSentinel" class="w-100 py-2"></div>

</div><!-- end #copy-asset tab pane -->

</div><!-- end .tab-content -->

</div>

<!-- Initialise the map -->
Expand Down Expand Up @@ -174,4 +239,267 @@ <h3>Location</h3>

</script>

<script type="module">
import { getAccount } from "{{ url_for('flexmeasures_ui.static', filename='js/ui-utils.js') }}?v={{ flexmeasures_version }}";

(function () {
const parentAssetId = "{{ parent_asset_id }}";
const perPage = 100;
let currentPage = 1;
let loadedAssetCount = 0;
let hasMore = true;
let isLoading = false;
let hasInitializedCopyTab = false;
let searchDebounceTimer = null;
let totalFilteredRecords = null;
let observer = null;
let activeSearchToken = 0;
const renderedAssetIds = new Set();
const accountNameCache = new Map();

const spinner = document.getElementById("copyAssetsSpinner");
const bottomSpinner = document.getElementById("copyAssetsBottomSpinner");
const results = document.getElementById("copyAssetsResults");
const status = document.getElementById("copyAssetsStatus");
const sentinel = document.getElementById("copyAssetsSentinel");

function toggleSpinner(show) {
if (show) {
spinner.classList.remove("d-none");
spinner.classList.add("d-flex");
} else {
spinner.classList.remove("d-flex");
spinner.classList.add("d-none");
}
}

function toggleBottomSpinner(show) {
if (show) {
bottomSpinner.classList.remove("d-none");
bottomSpinner.classList.add("d-flex");
} else {
bottomSpinner.classList.remove("d-flex");
bottomSpinner.classList.add("d-none");
}
}

function setStatusMessage(message) {
status.textContent = message || "";
}

function resetSearchState() {
activeSearchToken += 1;
currentPage = 1;
loadedAssetCount = 0;
hasMore = true;
isLoading = false;
totalFilteredRecords = null;
renderedAssetIds.clear();
results.innerHTML = "";
setStatusMessage("");
toggleBottomSpinner(false);
}

function getSearchParams(page) {
Comment thread
joshuaunity marked this conversation as resolved.
const filter = document.getElementById("copySearchInput").value;
const assetTypeId = document.getElementById("copyAssetTypeSelect").value;
const includePublic = document.getElementById("copyIncludePublic").checked;
Comment thread
joshuaunity marked this conversation as resolved.

const params = new URLSearchParams({ page: page, per_page: perPage });
if (filter) params.set("filter", filter);
if (assetTypeId) params.set("generic_asset_type_id", assetTypeId);
Comment thread
joshuaunity marked this conversation as resolved.
Outdated
if (includePublic) params.set("include_public_assets", "true");
Comment thread
joshuaunity marked this conversation as resolved.
Outdated
// Ensure stable ordering across pages to avoid page-overlap duplicates.
params.set("sort_by", "id");
params.set("sort_dir", "asc");
return params;
}

async function searchAssetsForCopy(reset) {
if (reset) {
resetSearchState();
}
if (isLoading || !hasMore) {
return;
}

isLoading = true;
if (reset) {
toggleSpinner(true);
toggleBottomSpinner(false);
} else {
toggleBottomSpinner(true);
}
const requestToken = activeSearchToken;
const params = getSearchParams(currentPage);

try {
const response = await fetch("/api/v3_0/assets?" + params.toString(), {
credentials: "same-origin",
});
if (!response.ok) throw new Error(response.statusText || "Request failed");
const data = await response.json();
if (requestToken !== activeSearchToken) {
return;
}
const assets = data.data || [];
totalFilteredRecords = data["filtered-records"];

if (!assets.length && currentPage === 1) {
setStatusMessage("No assets found.");
hasMore = false;
return;
}

renderCopyResults(assets);
loadedAssetCount += assets.length;

if (
assets.length < perPage ||
(typeof totalFilteredRecords === "number" &&
loadedAssetCount >= totalFilteredRecords)
) {
hasMore = false;
setStatusMessage("Nothing more to load.");
} else {
currentPage += 1;
setStatusMessage("Scroll down to load more.");
}
} catch (err) {
showToast("Failed to search assets: " + err.message, "error");
setStatusMessage("Failed to load assets.");
} finally {
isLoading = false;
toggleSpinner(false);
toggleBottomSpinner(false);
}
}

function renderCopyResults(assets) {
const container = document.getElementById("copyAssetsResults");
assets.forEach(function (asset) {
if (renderedAssetIds.has(asset.id)) {
return;
}
renderedAssetIds.add(asset.id);
const col = document.createElement("div");
col.className = "col";
const accountLabel = asset.account_id
? "Organisation #" + asset.account_id
: "Public";
col.innerHTML =
'<div class="d-flex justify-content-between align-items-center border rounded px-2 py-1">' +
'<div class="me-2 text-truncate">' +
'<div class="fw-semibold text-truncate">' + escapeHtml(asset.name) + ' (ID:' + asset.id + ')</div>' +
'<div class="text-muted small text-truncate js-account-label">' + accountLabel + '</div>' +
'</div>' +
'<button class="btn btn-sm btn-primary js-copy-from-tab-btn"' +
' data-asset-id="' + asset.id + '">Copy</button>' +
'</div>';
container.appendChild(col);

if (asset.account_id) {
getAccountLabel(asset.account_id).then(function (label) {
const accountLabelEl = col.querySelector(".js-account-label");
if (accountLabelEl) {
accountLabelEl.textContent = label;
}
});
}

// Attach listener after button is added to DOM
const btn = col.querySelector(".js-copy-from-tab-btn");
btn.addEventListener("click", function () {
const assetId = btn.dataset.assetId;
let url = "/api/v3_0/assets/" + assetId + "/copy";
if (parentAssetId) url += "?parent_id=" + parentAssetId;
btn.disabled = true;
showToast("Creating asset from template…", "info");
fetch(url, { method: "POST", credentials: "same-origin" })
.then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw new Error(e.message || r.statusText); });
return r.json();
})
.then(function (data) {
showToast("Asset created successfully.", "success");
setTimeout(function () {
window.location.href = "/assets/" + data.asset + "/properties";
}, 2000);
})
.catch(function (err) {
showToast("Failed to create asset: " + err.message, "error");
btn.disabled = false;
});
});
});
}

function initInfiniteScroll() {
if (observer) {
observer.disconnect();
}
observer = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
searchAssetsForCopy(false);
}
});
},
{
root: null,
rootMargin: "200px 0px",
threshold: 0,
},
);
observer.observe(sentinel);
}

function triggerNewSearch() {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(function () {
searchAssetsForCopy(true);
}, 250);
}

async function getAccountLabel(accountId) {
if (!accountId) {
return "Public";
}
if (accountNameCache.has(accountId)) {
return accountNameCache.get(accountId);
}
try {
const account = await getAccount(accountId);
const label = account && account.name
? account.name + " (ID:" + account.id + ")"
: "Organisation (ID:" + accountId + ")";
accountNameCache.set(accountId, label);
return label;
} catch (e) {
return "Organisation (ID:" + accountId + ")";
}
}

function escapeHtml(text) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(text));
return d.innerHTML;
}

document.getElementById("copySearchInput").addEventListener("input", triggerNewSearch);
document.getElementById("copyAssetTypeSelect").addEventListener("change", triggerNewSearch);
document.getElementById("copyIncludePublic").addEventListener("change", triggerNewSearch);

// Run initial search when the tab is first shown
document.getElementById("copy-asset-tab").addEventListener("shown.bs.tab", function () {
if (!hasInitializedCopyTab) {
hasInitializedCopyTab = true;
initInfiniteScroll();
searchAssetsForCopy(true);
}
});
}());
</script>

{% endblock %}
7 changes: 7 additions & 0 deletions flexmeasures/ui/views/assets/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
from sqlalchemy import select
from flask import redirect, url_for, current_app, request, session
from flask_classful import FlaskView, route
from flask_security import login_required, current_user
Expand All @@ -18,6 +19,7 @@
)
from flexmeasures.data.models.generic_assets import (
GenericAsset,
GenericAssetType,
get_bounding_box_of_assets,
)
from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema
Expand Down Expand Up @@ -147,6 +149,10 @@ def get(self, id: str, **kwargs):
if account: # Pre-set account
asset_form.account_id.data = str(account.id)

asset_types = db.session.scalars(
select(GenericAssetType).order_by(GenericAssetType.name)
).all()

return render_flexmeasures_template(
"assets/asset_new.html",
asset_form=asset_form,
Expand All @@ -157,6 +163,7 @@ def get(self, id: str, **kwargs):
parent_asset_name=parent_asset_name,
parent_asset_id=parent_asset_id,
account=account,
asset_types=asset_types,
)

# otherwise, redirect to the default asset view
Expand Down
Loading