diff --git a/etc/agrammon.yaml.dist b/etc/agrammon.yaml.dist index bff5f809..8a835dee 100644 --- a/etc/agrammon.yaml.dist +++ b/etc/agrammon.yaml.dist @@ -26,3 +26,31 @@ Model: technical: technical.cfg variant: SHL version: "6.0 - #REV#" + +# Sibling model versions reachable from this deployment. +# Each entry: { label, url, version, title } +# - label short string shown in the dropdown +# - url where to navigate when this version is picked +# - version matches the Model.version of the sibling instance; +# the entry whose version equals THIS process's +# Model.version is the active one. Switching to a strictly +# older version triggers a confirm dialog in the GUI. +# - title per-locale title shown in the GUI header for THIS version. +# The active entry's title overrides GUI.title. +# Order should be newest first. +# Leave the block out (or empty) to hide the dropdown. +Versions: + - label: '7.0' + url: /single/v7 + version: '7.0.0' + title: + de: AGRAMMON 7.0 Einzelbetriebsmodell + en: AGRAMMON 7.0 Single Farm Model + fr: AGRAMMON 7.0 modèle Exploitation individuelle + - label: '6.6' + url: /single/v6 + version: '6.6.0' + title: + de: AGRAMMON 6.6 Einzelbetriebsmodell + en: AGRAMMON 6.6 Single Farm Model + fr: AGRAMMON 6.6 modèle Exploitation individuelle diff --git a/frontend/source/class/agrammon/Application.js b/frontend/source/class/agrammon/Application.js index 64c5b851..60888e35 100644 --- a/frontend/source/class/agrammon/Application.js +++ b/frontend/source/class/agrammon/Application.js @@ -123,13 +123,18 @@ qx.Class.define('agrammon.Application', { navbar.setVariant(data.modelVariant); agrammon.module.dataset.DatasetTable.getInstance().setVariant(data.variant); + agrammon.module.dataset.DatasetTable.getInstance().setActiveVersion(data.version); mainMenu.setTitle(data.title, data.version); + // setVersions overrides the title with the entry that + // matches data.version (if a Versions block is configured). + mainMenu.setVersions(data.versions, data.version); let info = agrammon.Info.getInstance(); info.setVersion(data.version); info.setVariant(data.variant); info.setGuiVariant(data.guiVariant); info.setModelVariant(data.modelVariant); info.setTitle(data.title); + info.setVersions(data.versions); info.setSubmissionAddresses(data.submission); qx.event.message.Bus.dispatchByName('agrammon.Info.setModelVariant', data.modelVariant); diff --git a/frontend/source/class/agrammon/Info.js b/frontend/source/class/agrammon/Info.js index a9e986db..b1e66709 100644 --- a/frontend/source/class/agrammon/Info.js +++ b/frontend/source/class/agrammon/Info.js @@ -74,7 +74,10 @@ qx.Class.define( 'agrammon.Info', }, submissionAddresses: { nullable: true - } + }, + versions: { init: null, + nullable: true + } }, members : diff --git a/frontend/source/class/agrammon/module/dataset/DatasetTable.js b/frontend/source/class/agrammon/module/dataset/DatasetTable.js index a2fe8036..ae4b6442 100644 --- a/frontend/source/class/agrammon/module/dataset/DatasetTable.js +++ b/frontend/source/class/agrammon/module/dataset/DatasetTable.js @@ -217,7 +217,12 @@ qx.Class.define('agrammon.module.dataset.DatasetTable', { { variant: { init: null, check: "String" - } + }, + activeVersion: { init: null, + nullable: true, + check: "String", + apply: "_applyActiveVersion" + } }, members : @@ -235,11 +240,19 @@ qx.Class.define('agrammon.module.dataset.DatasetTable', { __btnClearReference: null, __table: null, + __rowRenderer: null, __searchTimer: null, __searchFilter: null, __filterHash: null, __datasetStore: null, + _applyActiveVersion: function(value) { + if (this.__rowRenderer) { + this.__rowRenderer.setActiveVersion(value || ''); + if (this.__table) this.__table.updateContent(); + } + }, + __searchTimeout: 250, // timeout after which SearchAsYouType view is updated __searchColumn: 0, // Dataset name __buttonRow: null, @@ -558,7 +571,15 @@ qx.Class.define('agrammon.module.dataset.DatasetTable', { padding: 0, showCellFocusIndicator: false }); - table.getDataRowRenderer().setHighlightFocusRow(false); + + // Custom row renderer fades rows whose dataset version doesn't + // match the active model version. Column 4 holds the version. + this.__rowRenderer = new agrammon.ui.table.rowrenderer.DatasetVersion(4); + this.__rowRenderer.setHighlightFocusRow(false); + if (this.getActiveVersion()) { + this.__rowRenderer.setActiveVersion(this.getActiveVersion()); + } + table.setDataRowRenderer(this.__rowRenderer); table.getSelectionModel().setSelectionMode(qx.ui.table.selection.Model.MULTIPLE_INTERVAL_SELECTION); var tcm = table.getTableColumnModel(); @@ -572,8 +593,10 @@ qx.Class.define('agrammon.module.dataset.DatasetTable', { tcmb.setWidth(this.__commentColumn,70); tcm.setColumnVisible(3,true); - tcm.setColumnVisible(4,false); - tcm.setColumnVisible(4,false); + // Version column is visible so users can see which model + // version each dataset belongs to (the row is faded if it + // doesn't match the active version). + tcm.setColumnVisible(4,true); // FIX ME: Column 5 must not be made visible, because it // has an array as value. Needs a specific cell renderer! tcm.setColumnVisible(5,false); diff --git a/frontend/source/class/agrammon/ui/menu/MainMenu.js b/frontend/source/class/agrammon/ui/menu/MainMenu.js index e45ab0a9..c4ff8653 100644 --- a/frontend/source/class/agrammon/ui/menu/MainMenu.js +++ b/frontend/source/class/agrammon/ui/menu/MainMenu.js @@ -31,6 +31,7 @@ qx.Class.define('agrammon.ui.menu.MainMenu', { editButton.setMenu(editMenu); var optionMenu = new agrammon.ui.menu.OptionMenu(); + this.__optionMenu = optionMenu; var optionButton = new qx.ui.menubar.Button(this.tr("Options")); optionButton.setMenu(optionMenu); @@ -68,6 +69,7 @@ qx.Class.define('agrammon.ui.menu.MainMenu', { this.addSpacer(); this.add(title); this.addSpacer(); + this.add(info); return; @@ -79,6 +81,7 @@ qx.Class.define('agrammon.ui.menu.MainMenu', { __adminMenu: null, __adminButton: null, __editButton: null, + __optionMenu: null, __title: null, // the Label widget __titles: null, // multi-lingual hash of title values __tooltip: null, @@ -106,6 +109,27 @@ qx.Class.define('agrammon.ui.menu.MainMenu', { }, + /** + * Forward the Versions block to the OptionMenu (which owns the + * "Set model version ..." submenu) and override the page title + * from the matching entry, if any. + * + * @param versions {Array} list of { label, url, version, title } + * @param activeVersion {String} Model.version string of THIS process + */ + setVersions: function(versions, activeVersion) { + this.__optionMenu.setVersions(versions, activeVersion); + + if (versions && versions.length) { + for (var i = 0; i < versions.length; i++) { + if (versions[i].version === activeVersion && versions[i].title) { + this.setTitle(versions[i].title, activeVersion); + break; + } + } + } + }, + __enableEdit: function(msg) { this.__editButton.setEnabled(msg.getData()); }, diff --git a/frontend/source/class/agrammon/ui/menu/OptionMenu.js b/frontend/source/class/agrammon/ui/menu/OptionMenu.js index 4c1031ac..41bffd2b 100644 --- a/frontend/source/class/agrammon/ui/menu/OptionMenu.js +++ b/frontend/source/class/agrammon/ui/menu/OptionMenu.js @@ -45,6 +45,16 @@ qx.Class.define('agrammon.ui.menu.OptionMenu', { var langButton = new qx.ui.menu.Button(this.tr("Set language ..."), null, null, langMenu); + // Model-version submenu. Hidden until setVersions() is called with + // a non-empty Versions block; populated lazily so labels match the + // current config and the active entry is disabled. + var versionMenu = new qx.ui.menu.Menu; + var versionButton = new qx.ui.menu.Button(this.tr("Set model version ..."), + null, null, versionMenu); + versionButton.exclude(); + this.__versionMenu = versionMenu; + this.__versionButton = versionButton; + var passwordDialog = new qx.ui.window.Window(this.tr("Changing password for ") + username, @@ -146,6 +156,7 @@ qx.Class.define('agrammon.ui.menu.OptionMenu', { var passwordButton = new qx.ui.menu.Button(this.tr("Change password"), null, passwordCommand); this.add(langButton); + this.add(versionButton); this.add(passwordButton); return; @@ -156,6 +167,98 @@ qx.Class.define('agrammon.ui.menu.OptionMenu', { { __rpc: null, __info: null, + __versionMenu: null, + __versionButton: null, + __activeVersion: null, + + /** + * Populate the model-version submenu. + * + * @param versions {Array} list of { label, url, version, title } + * @param activeVersion {String} Model.version string of THIS process + */ + setVersions: function(versions, activeVersion) { + this.__activeVersion = activeVersion; + this.__versionMenu.removeAll(); + + if (!versions || !versions.length) { + this.__versionButton.exclude(); + return; + } + + for (var i = 0; i < versions.length; i++) { + var v = versions[i]; + var btn = new qx.ui.menu.Button(v.label); + btn.setUserData('versionEntry', v); + if (v.version === activeVersion) { + // Can't switch to ourselves — make it visually obvious + // and unclickable. + btn.setEnabled(false); + } + else { + btn.addListener('execute', this.__onVersionPick, this); + } + this.__versionMenu.add(btn); + } + this.__versionButton.show(); + }, + + __onVersionPick: function(e) { + var entry = e.getTarget().getUserData('versionEntry'); + if (!entry) return; + + if (this.__isOlder(entry.version, this.__activeVersion)) { + var prevActive = this.__activeVersion; + var dialog = new agrammon.ui.dialog.Confirm( + this.tr("Switch to older model version?"), + this.tr("You are about to switch from version %1 to the older version %2. Older model versions may not understand datasets created in a newer one. Continue?", + prevActive, entry.version), + function () { + dialog.close(); + window.location.assign(entry.url); + }, + this, + false + ); + dialog.open(); + } + else { + window.location.assign(entry.url); + } + }, + + /** + * true iff `a` is strictly older than `b`. + * + * Only pure dotted-numeric versions (e.g. "6.6.0") are accepted. + * Pre-release tags (-rc1, -alpha), build metadata (+build), + * "v" prefixes and the like are not parsed: callers get a console + * warning and the version is treated as not-older, so navigation + * proceeds without the downgrade-confirm dialog. + */ + __isOlder: function(a, b) { + if (!a || !b) return false; + var pa = this.__parseVersion(a); + var pb = this.__parseVersion(b); + if (pa === null || pb === null) return false; + var n = Math.max(pa.length, pb.length); + for (var i = 0; i < n; i++) { + var x = pa[i] || 0, y = pb[i] || 0; + if (x < y) return true; + if (x > y) return false; + } + return false; + }, + + /** Returns Array for "1.2.3", or null (and warns) otherwise. */ + __parseVersion: function(v) { + var s = String(v); + if (!/^\d+(\.\d+)*$/.test(s)) { + this.warn("OptionMenu: version string '" + s + "' is not pure dotted-numeric; downgrade comparison disabled for it."); + return null; + } + return s.split('.').map(Number); + }, __changePassword: function(data, exc, id) { if (exc == null && ! data.error) { diff --git a/frontend/source/class/agrammon/ui/table/rowrenderer/DatasetVersion.js b/frontend/source/class/agrammon/ui/table/rowrenderer/DatasetVersion.js new file mode 100644 index 00000000..d549f054 --- /dev/null +++ b/frontend/source/class/agrammon/ui/table/rowrenderer/DatasetVersion.js @@ -0,0 +1,102 @@ +/* ************************************************************************ + + Row renderer for the dataset table that greys out rows whose dataset + version doesn't match the active model version of THIS process. + + The row data layout is the one produced by Agrammon::DB::Datasets.list: + [name, mod-date, records, read-only, version, tags, comment, model, is-demo] + +************************************************************************ */ + +qx.Class.define('agrammon.ui.table.rowrenderer.DatasetVersion', { + extend: qx.core.Object, + implement: qx.ui.table.IRowRenderer, + + construct: function(versionColumn) { + this.base(arguments); + this.__versionColumn = versionColumn; + + this.__fontStyle = qx.bom.Font.getDefaultStyles(); + this.__fontStyleString = qx.bom.element.Style.compile(this.__fontStyle).replace(/"/g, "'"); + + var colorMgr = qx.theme.manager.Color.getInstance(); + this._colors = { + bgcolFocused: colorMgr.resolve("table-row-background-focused"), + bgcolEven: colorMgr.resolve("table-row-background-even"), + bgcolOdd: colorMgr.resolve("table-row-background-odd"), + colNormal: colorMgr.resolve("table-row"), + horLine: colorMgr.resolve("table-row-line") + }; + }, + + properties: { + highlightFocusRow: { check: "Boolean", init: true }, + activeVersion: { check: "String", init: "", nullable: true } + }, + + members: { + __versionColumn: null, + __fontStyle: null, + __fontStyleString: null, + _colors: null, + _insetY: 1, + + __isMismatch: function(rowData) { + var v = this.getActiveVersion(); + if (!v || !rowData) return false; + var rowVersion = rowData[this.__versionColumn]; + return rowVersion != null && rowVersion !== '' && rowVersion !== v; + }, + + // interface implementation + updateDataRowElement: function(rowInfo, rowElem) { + var style = rowElem.style; + qx.bom.element.Style.setStyles(rowElem, this.__fontStyle); + + if (rowInfo.focusedRow && this.getHighlightFocusRow()) { + style.backgroundColor = this._colors.bgcolFocused; + } + else { + style.backgroundColor = (rowInfo.row % 2 == 0) + ? this._colors.bgcolEven + : this._colors.bgcolOdd; + } + + style.color = this._colors.colNormal; + style.borderBottom = "1px solid " + this._colors.horLine; + style.opacity = this.__isMismatch(rowInfo.rowData) ? "0.45" : "1"; + }, + + getRowHeightStyle: function(height) { + if (qx.core.Environment.get("css.boxmodel") == "content") { + height -= this._insetY; + } + return "height:" + height + "px;"; + }, + + // interface implementation + createRowStyle: function(rowInfo) { + var rowStyle = [";", this.__fontStyleString, "background-color:"]; + if (rowInfo.focusedRow && this.getHighlightFocusRow()) { + rowStyle.push(this._colors.bgcolFocused); + } + else { + rowStyle.push((rowInfo.row % 2 == 0) ? this._colors.bgcolEven + : this._colors.bgcolOdd); + } + rowStyle.push(';color:', this._colors.colNormal); + rowStyle.push(';border-bottom: 1px solid ', this._colors.horLine); + if (this.__isMismatch(rowInfo.rowData)) { + rowStyle.push(';opacity:0.45'); + } + return rowStyle.join(""); + }, + + getRowClass: function(rowInfo) { return ""; }, + getRowAttributes: function(rowInfo) { return ""; } + }, + + destruct: function() { + this._colors = this.__fontStyle = this.__fontStyleString = null; + } +}); diff --git a/frontend/source/translation/de.po b/frontend/source/translation/de.po index b5919c6c..4ce16259 100644 --- a/frontend/source/translation/de.po +++ b/frontend/source/translation/de.po @@ -993,3 +993,19 @@ msgstr "" #: agrammon/ui/menu/AdminMenu.js msgid "%1 errors" msgstr "" + +#. NO LONGER USED +msgid "Switch model version" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Switch to older model version?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "You are about to switch from version %1 to the older version %2. Older model versions may not understand datasets created in a newer one. Continue?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Set model version ..." +msgstr "" diff --git a/frontend/source/translation/en.po b/frontend/source/translation/en.po index d2dcb61a..10f5853d 100644 --- a/frontend/source/translation/en.po +++ b/frontend/source/translation/en.po @@ -993,3 +993,19 @@ msgstr "" #: agrammon/ui/menu/AdminMenu.js msgid "%1 errors" msgstr "" + +#. NO LONGER USED +msgid "Switch model version" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Switch to older model version?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "You are about to switch from version %1 to the older version %2. Older model versions may not understand datasets created in a newer one. Continue?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Set model version ..." +msgstr "" diff --git a/frontend/source/translation/fr.po b/frontend/source/translation/fr.po index 25b31b95..4ebcefe9 100644 --- a/frontend/source/translation/fr.po +++ b/frontend/source/translation/fr.po @@ -994,3 +994,19 @@ msgstr "" #: agrammon/ui/menu/AdminMenu.js msgid "%1 errors" msgstr "" + +#. NO LONGER USED +msgid "Switch model version" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Switch to older model version?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "You are about to switch from version %1 to the older version %2. Older model versions may not understand datasets created in a newer one. Continue?" +msgstr "" + +#: agrammon/ui/menu/OptionMenu.js +msgid "Set model version ..." +msgstr "" diff --git a/lib/Agrammon/Config.rakumod b/lib/Agrammon/Config.rakumod index 47f851c8..2c014a99 100644 --- a/lib/Agrammon/Config.rakumod +++ b/lib/Agrammon/Config.rakumod @@ -7,6 +7,7 @@ class Agrammon::Config { has %.database; has %.gui; has %.model; + has @.versions; has %.translations; has $!base-url; @@ -18,6 +19,7 @@ class Agrammon::Config { %!database = $config; %!gui = $config; %!model = $config; + @!versions = ($config // ()).list; %!translations = self!get-translations; $!base-url = $config; } diff --git a/lib/Agrammon/DB/Datasets.rakumod b/lib/Agrammon/DB/Datasets.rakumod index 68046507..0a1b90fc 100644 --- a/lib/Agrammon/DB/Datasets.rakumod +++ b/lib/Agrammon/DB/Datasets.rakumod @@ -11,7 +11,11 @@ class Agrammon::DB::Datasets does Agrammon::DB::Variant { method load { self.with-db: -> $db { my $username = $!user.username; - my $results = $db.query(q:to/DATASETS/, ~$username, |self!variant); + # Cross-version listing: don't filter by dataset_version here so + # the GUI can show datasets from sibling model versions and + # grey out the ones that don't match the active version. + my @gui-model = %!agrammon-variant; + my $results = $db.query(q:to/DATASETS/, ~$username, |@gui-model); SELECT dataset_id AS id, dataset_name AS name, date_trunc('seconds', dataset_mod_date) AS "mod-date", @@ -26,8 +30,7 @@ class Agrammon::DB::Datasets does Agrammon::DB::Variant { dataset_model AS model, dataset_pers != pers_email2id($1) AS "is-demo" FROM dataset - WHERE dataset_version = $2 - AND (dataset_model = 'UNKNOWN' OR dataset_guivariant= $3 AND dataset_modelvariant = $4) + WHERE (dataset_model = 'UNKNOWN' OR dataset_guivariant= $2 AND dataset_modelvariant = $3) AND dataset_name NOT LIKE '%_expanded' AND (dataset_pers=pers_email2id($1) OR dataset_pers=pers_email2id('default') OR dataset_pers=pers_email2id('default2')) diff --git a/lib/Agrammon/Web/Service.rakumod b/lib/Agrammon/Web/Service.rakumod index 6e3aea26..05f4b719 100644 --- a/lib/Agrammon/Web/Service.rakumod +++ b/lib/Agrammon/Web/Service.rakumod @@ -46,6 +46,7 @@ class Agrammon::Web::Service { version => %model, submission => %gui, baseUrl => %gui, + versions => $!cfg.versions, ); return %cfg; } diff --git a/t/config.rakutest b/t/config.rakutest index 599cb981..29f2dadb 100644 --- a/t/config.rakutest +++ b/t/config.rakutest @@ -2,7 +2,7 @@ use v6; use Agrammon::Config; use Test; -plan 10; +plan 12; my %config-expected = ( General => { @@ -61,4 +61,23 @@ subtest "Translations" => { is $cfg.translations, 'Résultats', 'French for results'; } +is $cfg.versions.elems, 0, 'No Versions: block -> empty versions list'; + +subtest "Versions block parsed" => { + my $vfile = "t/test-data/agrammon.cfg-with-versions.yaml"; + my $vcfg = Agrammon::Config.new; + lives-ok { $vcfg.load($vfile) }, "Load config with Versions: from $vfile"; + + is $vcfg.versions.elems, 2, 'Two Versions entries'; + + my $entry = $vcfg.versions[0]; + is $entry