Fix the edit form so Save persists stage changes

Changing an application's stage and pressing Save closed the form
without saving the stage: JobsDatabase::updateJob() deliberately skips
the stage column so every move lands in stage_history, but
JobEditModel::save() never followed up with setStage(). It now does,
as the CLI's update command always has.

Also:
- Validate that company and title are non-empty and say so, instead of
  saving a blank field
- Always set lastError on failure, clear it on the next attempt, and
  bind the page's error message to it
- Reload the form's values when jobsModel is assigned, since QML does
  not guarantee it is set before editingJobId
- Bind the salary spin boxes to the model and write back only on user
  edits; guard the notes field the same way
- Add jobeditmodeltest covering load order, edits, stage history,
  salaries round-tripping, adding, and validation errors

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
This commit is contained in:
2026-09-11 15:41:48 -05:00
co-authored by Claude Opus 5
parent 92339853d3
commit aa3a31f014
5 changed files with 267 additions and 13 deletions
+52 -5
View File
@@ -6,6 +6,8 @@
#include "jobeditmodel.h"
#include <KLocalizedString>
#include <QDate>
using namespace Qt::Literals::StringLiterals;
@@ -96,6 +98,10 @@ void JobEditModel::setJobsModel(JobsModel *model)
return;
}
m_jobsModel = model;
// QML does not guarantee editingJobId is assigned after jobsModel; if it
// came first, the resetValues() it triggered had no model to load from
// and only filled new-job defaults.
resetValues();
Q_EMIT jobsModelChanged();
}
@@ -117,6 +123,7 @@ void JobEditModel::setEditingJobId(int id)
void JobEditModel::resetValues()
{
m_values.clear();
m_loadedStage.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_values[u"currency"_s] = u"USD"_s;
@@ -138,6 +145,7 @@ void JobEditModel::resetValues()
}
m_values[field.id] = value;
}
m_loadedStage = m_values.value(u"stage"_s).toString();
}
if (rowCount() > 0) {
@@ -170,7 +178,19 @@ void JobEditModel::setValue(int row, const QVariant &value)
bool JobEditModel::save()
{
setLastError(QString());
if (!m_jobsModel) {
setLastError(i18n("Cannot save: no applications list is attached to this form."));
return false;
}
if (m_values.value(u"company"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Company is required."));
return false;
}
if (m_values.value(u"title"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Job title is required."));
return false;
}
@@ -187,12 +207,39 @@ bool JobEditModel::save()
}
}
const bool ok = m_editingJobId < 0 ? m_jobsModel->addJob(fields) : m_jobsModel->updateJob(m_editingJobId, fields);
if (!ok) {
m_lastError = m_jobsModel->lastError();
Q_EMIT lastErrorChanged();
if (m_editingJobId < 0) {
if (!m_jobsModel->addJob(fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
return true;
}
return ok;
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
// updateJob() deliberately never writes the stage (so every stage change
// lands in stage_history); route a changed stage through setStage().
const QString stage = fields.value(u"stage"_s).toString();
if (!stage.isEmpty() && stage != m_loadedStage) {
if (!m_jobsModel->setStage(m_editingJobId, stage)) {
setLastError(m_jobsModel->lastError());
return false;
}
m_loadedStage = stage;
}
return true;
}
void JobEditModel::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
Q_EMIT lastErrorChanged();
}
bool JobEditModel::deleteJob()
+2
View File
@@ -74,10 +74,12 @@ Q_SIGNALS:
private:
void resetValues();
void setLastError(const QString &error);
JobsModel *m_jobsModel = nullptr;
int m_editingJobId = -1;
QHash<QString, QVariant> m_values;
QString m_lastError;
QString m_loadedStage; ///< Stage as loaded from the database, to detect stage changes on save.
QList<JobFieldCatalog::Field> m_fields;
};
+14 -8
View File
@@ -56,11 +56,10 @@ Kirigami.ScrollablePage {
Kirigami.Action {
text: i18nc("@action:button", "Save")
icon.name: "document-save"
// On failure, editModel.lastError is set and shown by errorLabel.
onTriggered: {
if (editModel.save()) {
root.done();
} else {
errorLabel.text = editModel.lastError;
}
}
}
@@ -76,6 +75,7 @@ Kirigami.ScrollablePage {
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
text: editModel.lastError
visible: text.length > 0
}
@@ -204,11 +204,11 @@ Kirigami.ScrollablePage {
from: spinD.model.spinMin
to: spinD.model.spinMax
stepSize: 1000
// One-time init, not a persistent binding: this also
// writes back on user edits, so binding "value" live to
// spinD.model.value would be a binding loop.
Component.onCompleted: value = spinD.model.value
onValueChanged: editModel.setValue(spinD.sourceRow, value)
// Live binding so values loaded after the delegate is
// created still show up. Writing back only on
// valueModified (user edits) avoids a binding loop.
value: spinD.model.value
onValueModified: editModel.setValue(spinD.sourceRow, value)
}
Kirigami.Separator {
@@ -279,7 +279,13 @@ Kirigami.ScrollablePage {
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
wrapMode: TextEdit.Wrap
text: areaD.value
onTextChanged: editModel.setValue(areaD.sourceRow, text)
// TextArea has no textEdited signal; skip the echo from
// the model-driven binding and only write real edits.
onTextChanged: {
if (text !== areaD.value) {
editModel.setValue(areaD.sourceRow, text);
}
}
}
Kirigami.Separator {