[Client] Keep Archidekt navigation tied to its active request (#7723)

* [Client] Keep Archidekt navigation tied to its active request [skip ci]

* [Client] Keep Archidekt listing pagination after a failed navigation

Only update list mode and the next-page link when a listing or deck
response is accepted, so a failed deck navigation leaves the existing
listing scrollable. Retry still re-issues the failed request.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
Bryce! 2026-11-03 23:59:17 -05:00 • committed by GitHub
parent 81a0b97240
commit 75d7764643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 136 additions and 42 deletions

View file

@ -25,6 +25,7 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValueRef> #include <QJsonValueRef>
#include <QLabel> #include <QLabel>
#include <QLayoutItem> #include <QLayoutItem>
@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
{ {
// Initialize network // Initialize network
networkManager = NetworkManagerFactory::create(this); networkManager = NetworkManagerFactory::create(this);
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson);
// Initialize debounce timer // Initialize debounce timer
searchDebounceTimer = new QTimer(this); searchDebounceTimer = new QTimer(this);
@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
getTopDecks(); getTopDecks();
} }
TabArchidekt::~TabArchidekt()
{
cancelRequest();
}
void TabArchidekt::cancelRequest()
{
const QPointer<QNetworkReply> previousReply = activeReply;
activeReply = nullptr;
isLoadingMore = false;
if (previousReply) {
disconnect(previousReply, nullptr, this, nullptr);
previousReply->abort();
previousReply->deleteLater();
}
}
void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page)
{
cancelRequest();
requestUrl = url;
requestKind = kind;
requestedPage = page;
isLoadingMore = true;
retryButton->hide();
requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…"));
requestStatusLabel->show();
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
auto *reply = networkManager->get(request);
activeReply = reply;
connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); });
}
void TabArchidekt::showRequestError(const QString &error)
{
requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error));
requestStatusLabel->show();
retryButton->show();
}
void TabArchidekt::initializeUi() void TabArchidekt::initializeUi()
{ {
// Main container // Main container
@ -194,6 +235,18 @@ void TabArchidekt::initializeUi()
mainLayout->addWidget(primaryToolbar); mainLayout->addWidget(primaryToolbar);
mainLayout->addWidget(secondaryToolbar); mainLayout->addWidget(secondaryToolbar);
auto *requestStatusRow = new QHBoxLayout;
requestStatusLabel = new QLabel(container);
requestStatusLabel->setTextFormat(Qt::PlainText);
requestStatusLabel->setWordWrap(true);
retryButton = new QPushButton(tr("Retry"), container);
retryButton->hide();
connect(retryButton, &QPushButton::clicked, this,
[this]() { startRequest(requestUrl, requestKind, requestedPage); });
requestStatusRow->addWidget(requestStatusLabel, 1);
requestStatusRow->addWidget(retryButton);
mainLayout->addLayout(requestStatusRow);
mainLayout->addWidget(scrollArea); mainLayout->addWidget(scrollArea);
setCentralWidget(container); setCentralWidget(container);
@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi()
minDeckSizeLogicCombo->setItemText(0, tr("Exact")); minDeckSizeLogicCombo->setItemText(0, tr("Exact"));
searchButton->setText(tr("Search")); searchButton->setText(tr("Search"));
retryButton->setText(tr("Retry"));
settingsButton->setToolTip(tr("Display Settings")); settingsButton->setToolTip(tr("Display Settings"));
} }
@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl()
void TabArchidekt::doSearch() void TabArchidekt::doSearch()
{ {
// Reset to first page on new search
currentPage = 1;
// We're searching, so we'll be in list mode
isListMode = true;
// Don't debounce - only called by explicit user actions now // Don't debounce - only called by explicit user actions now
doSearchImmediate(); doSearchImmediate();
} }
void TabArchidekt::doSearchImmediate() void TabArchidekt::doSearchImmediate()
{ {
QString url = buildSearchUrl(); searchDebounceTimer->stop();
QNetworkRequest req{QUrl(url)}; currentPage = 1;
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); startRequest(QUrl(buildSearchUrl()), RequestKind::Search);
networkManager->get(req);
} }
void TabArchidekt::loadNextPage() void TabArchidekt::loadNextPage()
{ {
if (isLoadingMore) { if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) {
return; return;
} }
isLoadingMore = true; // Continue the accepted search, even if the user has edited filters without submitting them.
currentPage++; // Advance currentPage only after this response has been accepted, so failures can retry it.
startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1);
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
} }
void TabArchidekt::actNavigatePage(QString url) void TabArchidekt::actNavigatePage(QString url)
{ {
QNetworkRequest request{QUrl(url)}; searchDebounceTimer->stop();
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); startRequest(QUrl(url), RequestKind::Deck);
networkManager->get(request);
} }
void TabArchidekt::getTopDecks() void TabArchidekt::getTopDecks()
{ {
currentPage = 1; doSearchImmediate();
QNetworkRequest request{QUrl(buildSearchUrl())};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
} }
void TabArchidekt::processApiJson(QNetworkReply *reply) void TabArchidekt::processApiJson(QNetworkReply *reply)
{ {
if (reply->error() != QNetworkReply::NoError) { if (reply != activeReply) {
isLoadingMore = false;
reply->deleteLater(); reply->deleteLater();
return; return;
} }
activeReply = nullptr;
isLoadingMore = false;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showRequestError(reply->errorString());
return;
}
QByteArray responseData = reply->readAll(); QByteArray responseData = reply->readAll();
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); QJsonParseError parseError;
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError);
if (!jsonDoc.isObject()) { if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
isLoadingMore = false; showRequestError(tr("Invalid response from Archidekt."));
reply->deleteLater();
return; return;
} }
QJsonObject jsonObj = jsonDoc.object(); QJsonObject jsonObj = jsonDoc.object();
QString responseUrl = reply->url().toString(); if (requestKind != RequestKind::Deck) {
if (!jsonObj.value("results").isArray() ||
if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { (!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
for (const auto &value : jsonObj.value("results").toArray()) {
if (!value.isObject() || value.toObject().value("id").toInt() <= 0) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
}
currentPage = requestedPage;
const QString next = jsonObj.value("next").toString();
nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next));
// An empty or self-repeating page cannot make forward progress.
if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) {
nextPageUrl.clear();
}
requestStatusLabel->setText(
currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString());
requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty());
processTopDecksResponse(jsonObj); processTopDecksResponse(jsonObj);
} else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) {
processDeckResponse(jsonObj);
} else { } else {
prettyPrintJson(jsonObj, 4); if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) {
showRequestError(tr("Invalid deck from Archidekt."));
return;
}
requestStatusLabel->hide();
processDeckResponse(jsonObj);
} }
isLoadingMore = false;
reply->deleteLater();
} }
void TabArchidekt::processTopDecksResponse(QJsonObject reply) void TabArchidekt::processTopDecksResponse(QJsonObject reply)
@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply)
ArchidektDeckListingApiResponse deckData; ArchidektDeckListingApiResponse deckData;
deckData.fromJson(reply); deckData.fromJson(reply);
// An accepted listing restores list mode; a failed deck navigation leaves the listing usable.
isListMode = true;
// New search → clear everything // New search → clear everything
if (currentPage == 1) { if (requestKind == RequestKind::Search) {
listingsWidget = nullptr;
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider);
@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply)
// We're in single deck mode - disable infinite scroll // We're in single deck mode - disable infinite scroll
isListMode = false; isListMode = false;
nextPageUrl.clear();
// Clear existing results for single deck view // Clear existing results for single deck view
listingsWidget = nullptr;
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider);

View file

@ -8,8 +8,10 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue> #include <QJsonValue>
#include <QList> #include <QList>
#include <QPointer>
#include <QSet> #include <QSet>
#include <QString> #include <QString>
#include <QUrl>
#include <QVector> #include <QVector>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -55,6 +57,7 @@ public:
* connects signals and slots, and triggers an initial fetch of top decks. * connects signals and slots, and triggers an initial fetch of top decks.
*/ */
explicit TabArchidekt(TabSupervisor *_tabSupervisor); explicit TabArchidekt(TabSupervisor *_tabSupervisor);
~TabArchidekt() override;
/** /**
* @brief Update all UI text to reflect the current language or translation * @brief Update all UI text to reflect the current language or translation
@ -190,6 +193,17 @@ protected:
bool eventFilter(QObject *obj, QEvent *event) override; bool eventFilter(QObject *obj, QEvent *event) override;
private: private:
enum class RequestKind
{
Search,
NextPage,
Deck
};
void startRequest(QUrl url, RequestKind kind, int page = 1);
void cancelRequest();
void showRequestError(const QString &error);
/** /**
* @brief Initialize the main UI layout and toolbars * @brief Initialize the main UI layout and toolbars
* *
@ -236,7 +250,14 @@ private:
int currentPage; ///< Current page number for infinite scroll int currentPage; ///< Current page number for infinite scroll
bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads
bool isListMode; bool isListMode;
ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr; QPointer<QNetworkReply> activeReply;
RequestKind requestKind = RequestKind::Search;
QUrl requestUrl;
QUrl nextPageUrl;
int requestedPage = 1;
QPointer<ArchidektApiResponseDeckListingsDisplayWidget> listingsWidget;
QLabel *requestStatusLabel;
QPushButton *retryButton;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Layout Containers // Layout Containers