From c3c0779d930d4d0367ec0517166ca21f6b3dc6d4 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Thu, 1 Oct 2026 15:40:24 +0800 Subject: [PATCH] fix: localize Receiver validation labels in Web Console 1.6.164 --- COMPATIBILITY.md | 11 ++ README.md | 13 ++ app/mixins/cattle-transitioning-resource.js | 28 +++ app/models/scalehost.js | 3 +- app/models/scaleservice.js | 3 +- ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.164.md | 52 ++++++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- .../mixins/receiver-validation-labels-test.js | 175 ++++++++++++++++++ 13 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 docs/releases/web-console-1.6.164.md create mode 100644 tests/unit/mixins/receiver-validation-labels-test.js diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index f0da0aa26a..6f42179c24 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,17 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Web Console `1.6.164` is a source-only Receiver validation-label candidate. +Required-field and numeric errors reuse the visible labels from the Receiver, +scale-service, scale-host and service-upgrade forms. Embedded schema scopes, +model-specific translation precedence and unknown-field fallback are retained. +The existing scale minimum/maximum condition is unchanged; only its message +uses the existing numeric translation. No required, numeric or save guard, +API/schema, permission, payload or driver action changes. Focused local source +validation passed 12/12 tests and 61/61 assertions; full CI, component +publication and packaged-browser acceptance remain +separate. Published `1.6.163` and earlier HOLD evidence are not overwritten. + Published Web Console `1.6.163` localizes state badges only from seven recognized `model.displayState` labels using existing translation keys. Unknown labels and health/connection display overrides retain their original text; a machine state diff --git a/README.md b/README.md index d545838634..b9ec8eda93 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Source candidate `1.6.164` reuses visible Receiver form labels for shared +required-field and numeric validation errors. The three supported driver +configurations retain their own label scopes; model-specific translations still +take precedence and unknown fields keep their existing fallback. The scale +models' existing minimum-greater-than-maximum checks use the existing localized +numeric error. No validation rule, API, schema, permission or driver behavior is +changed. Focused Chrome 153 source validation passed 12/12 tests with 61/61 +assertions (eight new Receiver cases and four adjacent existing cases); no +`1.6.164` archive, full CI, +Server packaging, deployment or complete-language/matrix acceptance is claimed. +Published `1.6.163` and historical HOLDs remain unchanged. See the +[candidate note](docs/releases/web-console-1.6.164.md). + Published release `1.6.163` contains two shared display-locale fixes. Relative dates recompute when the selected language changes and use a Moment instance locale, without changing the global locale during formatting. State diff --git a/app/mixins/cattle-transitioning-resource.js b/app/mixins/cattle-transitioning-resource.js index a55a3b26ae..1ae83f852c 100644 --- a/app/mixins/cattle-transitioning-resource.js +++ b/app/mixins/cattle-transitioning-resource.js @@ -76,6 +76,34 @@ const formValidationLabels = { description: 'formNameDescription.description.label', value: 'newSecret.value.label', }, + receiver: { + name: 'generic.name', + driver: 'newReceiver.driver.label', + }, + scaleservice: { + action: 'newReceiver.action.label', + serviceId: 'newReceiver.service.label', + amount: 'newReceiver.amount.label', + min: 'newReceiver.min.label', + max: 'newReceiver.max.label', + }, + scalehost: { + action: 'newReceiver.action.label', + hostSelector: 'newReceiver.hostSelector.label', + amount: 'newReceiver.amount.label', + min: 'newReceiver.min.label', + max: 'newReceiver.max.label', + deleteOption: 'newReceiver.deleteOption.label', + }, + serviceupgrade: { + payloadFormat: 'newReceiver.payloadFormat.label', + addressType: 'newReceiver.addressType.label', + tag: 'newReceiver.tag.label', + serviceSelector: 'newReceiver.serviceSelector.label', + batchSize: 'formUpgrade.size', + intervalMillis: 'formUpgrade.interval', + startFirst: 'formUpgrade.behavior', + }, }; const stateColorSortMap = { diff --git a/app/models/scalehost.js b/app/models/scalehost.js index 5f0102e26c..c53afc2c93 100644 --- a/app/models/scalehost.js +++ b/app/models/scalehost.js @@ -14,7 +14,8 @@ export default Resource.extend({ let min = parseInt(this.get('min'),10); let max = parseInt(this.get('max'),10); if ( min && max && min > max ) { - errors.push('"Minimum Scale" cannot be greater than "Maximum Scale"'); + let intl = this.get('intl'); + errors.push(intl.t('validation.number.max', {key: intl.t('newReceiver.min.label'), val: max})); } return errors; diff --git a/app/models/scaleservice.js b/app/models/scaleservice.js index 301a5fc2d6..e129cb9911 100644 --- a/app/models/scaleservice.js +++ b/app/models/scaleservice.js @@ -6,7 +6,8 @@ export default Resource.extend({ let min = parseInt(this.get('min'),10); let max = parseInt(this.get('max'),10); if ( min && max && min > max ) { - errors.push('"Minimum Scale" cannot be greater than "Maximum Scale"'); + let intl = this.get('intl'); + errors.push(intl.t('validation.number.max', {key: intl.t('newReceiver.min.label'), val: max})); } return errors; diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 88b822b10d..822402e22f 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.163", + "version": "1.6.164", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.163", + "version": "1.6.164", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.164.md b/docs/releases/web-console-1.6.164.md new file mode 100644 index 0000000000..577ce7e23b --- /dev/null +++ b/docs/releases/web-console-1.6.164.md @@ -0,0 +1,52 @@ +# Web Console 1.6.164 + +SOURCE-CANDIDATE ONLY, not a published component or final Server candidate. +Based on the published `1.6.163` runtime source +`5db737a5e04c4cc15672f97296d5bf521ab9a8da` and its merged publication documents +at `7fbc755379e07e0230f1a5fb76873197924eb56a`. No archive, tag, release, +Server packaging, deployment or complete resource/role matrix PASS is claimed. + +## Receiver validation labels + +The shared validation-label fallback now uses `generic.name` for Receiver Name +and `newReceiver.service.label` for the scale-service target. It also reuses +the actual visible labels for required or numeric fields in the three supported +Receiver drivers. Service-upgrade batch size and interval use `formUpgrade.size` +and `formUpgrade.interval`, matching the template rather than a similarly named +unused label. The mappings are scoped to normalized Receiver/config types; +model-specific labels and direct model translations retain precedence. Unknown +fields and unrelated resources retain their original fallback. + +Scale-service and scale-host retain the existing `min && max && min > max` +condition. Its hardcoded English message is replaced by the existing +`validation.number.max` translation and the visible minimum label. No new +translation keys, validation conditions, schema, API, authorization, driver +action, clone behavior or resource request changes are included. The published +`1.6.163` state-badge and relative-date fixes remain byte-for-byte unchanged. + +## Focused evidence and pending acceptance + +Focused local QUnit validation passed 12/12 tests and 61/61 assertions in native +Chrome `153.0.8010.53`, with zero failures, skipped tests or todos. Eight new +Receiver tests and four adjacent existing validation tests ran; this used the +existing dependency installation, not a fresh locked official CI run. +The new tests call the actual shared +`validationErrors` implementation and real scale models with the actual Intl +service and built English/Traditional Chinese translation JSON. They cover +language switching, nested driver scopes, required/numeric labels, model-label +precedence, unknown fallback and unchanged valid/boundary behavior. Adjacent +existing required-field/update-omission tests are included in the same filter. + +The first local attempt had two assertion failures because its expected +scale-service amount label incorrectly bypassed the existing model-specific +translation. The fixture was corrected to retain that precedence; runtime +semantics were not changed to satisfy the test. The original failure evidence +is retained, separate from the corrected successful run. + +The numeric package bump changes only package/lock root versions and directly +dependent gate version literals; dependency graphs and security thresholds are +unchanged. Full official CI, deterministic production archives and native +packaged-browser acceptance have not been run for this candidate. This does not +promote the earlier native zero-write Receiver receipt to localized-error PASS, +complete all-language acceptance or resolve the broader incomplete matrix. +Historical HOLD evidence remains HOLD. diff --git a/package-lock.json b/package-lock.json index 88b822b10d..822402e22f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.163", + "version": "1.6.164", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.163", + "version": "1.6.164", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 5be0c0fbb7..05fd9c610c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.163", + "version": "1.6.164", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 83b1cb5853..d91d357757 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.163" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.163" +if [[ "$version" != "1.6.164" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.164" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index d449ac807f..d8e5f34a01 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.163 browser-session broker-broadcast + 1.6.164 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index e4d11d3779..1c49b26a1f 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.163": +if package.get("version") != "1.6.164": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/tests/unit/mixins/receiver-validation-labels-test.js b/tests/unit/mixins/receiver-validation-labels-test.js new file mode 100644 index 0000000000..c2935f80c0 --- /dev/null +++ b/tests/unit/mixins/receiver-validation-labels-test.js @@ -0,0 +1,175 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { setupContext, teardownContext } from '@ember/test-helpers'; +import { module, test } from 'qunit'; + +import CattleTransitioningResource from 'ui/mixins/cattle-transitioning-resource'; +import ScaleService from 'ui/models/scaleservice'; +import ScaleHost from 'ui/models/scalehost'; +import { initialize as initializeResource } from 'ui/initializers/extend-resource'; +import { initialize as initializeIntl } from 'ui/instance-initializers/intl'; +import resolver from '../../helpers/resolver'; + +const visibleLabels = { + receiver: {name: 'generic.name', driver: 'newReceiver.driver.label'}, + scaleService: { + action: 'newReceiver.action.label', serviceId: 'newReceiver.service.label', + amount: 'newReceiver.amount.label', min: 'newReceiver.min.label', max: 'newReceiver.max.label', + }, + scaleHost: { + action: 'newReceiver.action.label', hostSelector: 'newReceiver.hostSelector.label', + amount: 'newReceiver.amount.label', min: 'newReceiver.min.label', max: 'newReceiver.max.label', + deleteOption: 'newReceiver.deleteOption.label', + }, + serviceUpgrade: { + payloadFormat: 'newReceiver.payloadFormat.label', addressType: 'newReceiver.addressType.label', + tag: 'newReceiver.tag.label', serviceSelector: 'newReceiver.serviceSelector.label', + batchSize: 'formUpgrade.size', intervalMillis: 'formUpgrade.interval', startFirst: 'formUpgrade.behavior', + }, +}; + +function expectedLabel(intl, type, key) { + let modelKey = `model.${type}.${key}`; + if (intl.exists(`${modelKey}.label`)) { + return intl.t(`${modelKey}.label`); + } + if (intl.exists(modelKey)) { + return intl.t(modelKey); + } + return intl.t(visibleLabels[type][key]); +} + +module('Unit | Mixin | cattle transitioning resource validation | receiver labels', function(hooks) { + hooks.beforeEach(async function() { + await setupContext(this, {resolver}); + initializeIntl(this.owner); + initializeResource(); + this.intl = this.owner.lookup('service:intl'); + this.subjects = []; + for (let locale of ['en-us', 'zh-tw']) { + let response = await fetch(`/translations/${locale}.json`); + if (!response.ok) { + throw new Error(`Local translation fixture failed: ${locale} ${response.status}`); + } + this.intl.addTranslations(locale, await response.json()); + } + this.intl.setLocale(['en-us']); + this.subject = (type, fields, values = {}, Factory) => { + let Subject = Factory || EmberObject.extend(CattleTransitioningResource); + let subject = Subject.extend({trimValues() {}}).create({ + type, ...values, intl: this.intl, + store: EmberObject.create({ + getById(schemaType, schemaId) { + return schemaType === 'schema' && schemaId === type.toLowerCase() ? {resourceFields: fields} : null; + }, + }), + }); + this.subjects.push(subject); + return subject; + }; + }); + + hooks.afterEach(async function() { + run(() => this.subjects.forEach((subject) => subject.destroy())); + await teardownContext(this); + }); + + test('the actual empty Receiver and target-service errors follow en to zh-tw to en', function(assert) { + let config = this.subject('scaleService', {serviceId: {type: 'string', required: true}}, {serviceId: ''}); + let receiver = this.subject('receiver', { + name: {type: 'string', required: true}, scaleServiceConfig: {type: 'scaleService'}, + }, {name: '', scaleServiceConfig: config}); + + assert.deepEqual(receiver.validationErrors(), ['"Name" is required', '"Target Service" is required']); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + assert.deepEqual(receiver.validationErrors(), ['"名稱" 必須設定', '"目標服務" 必須設定']); + run(() => this.intl.setLocale(['en-us'])); + assert.deepEqual(receiver.validationErrors(), ['"Name" is required', '"Target Service" is required']); + }); + + test('all three nested driver scopes reuse their own visible required-field labels', function(assert) { + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + for (let type of ['scaleService', 'scaleHost', 'serviceUpgrade']) { + let labels = visibleLabels[type]; + let fields = Object.fromEntries(Object.keys(labels).map((key) => [key, {type: 'string', required: true}])); + let config = this.subject(type, fields); + let receiver = this.subject('receiver', {[`${type}Config`]: {type}}, {[`${type}Config`]: config}); + assert.deepEqual(receiver.validationErrors(), Object.keys(labels).map((key) => + this.intl.t('validation.required', {key: expectedLabel(this.intl, type, key)})), `${type} labels remain scoped to that embedded schema`); + } + let receiver = this.subject('receiver', {driver: {type: 'enum', required: true}}); + assert.deepEqual(receiver.validationErrors(), ['"類型" 必須設定']); + }); + + test('scale numeric errors use By, Minimum Scale and Maximum Scale in each selected locale', function(assert) { + for (let locale of ['en-us', 'zh-tw']) { + run(() => this.intl.setLocale([locale, 'en-us'])); + for (let type of ['scaleService', 'scaleHost']) { + let fields = {amount: {type: 'int', min: 1}, min: {type: 'int', min: 1}, max: {type: 'int', min: 1}}; + let subject = this.subject(type, fields, {amount: -1, min: -1, max: -1}); + assert.deepEqual(subject.validationErrors(), ['amount', 'min', 'max'].map((key) => + this.intl.t('validation.number.min', {key: expectedLabel(this.intl, type, key), val: 1})), `${locale} ${type}`); + } + } + }); + + test('upgrade numeric errors match the formUpgrade labels actually displayed by its template', function(assert) { + let config = this.subject('serviceUpgrade', {batchSize: {type: 'int', min: 1}, intervalMillis: {type: 'int', min: 1}}, + {batchSize: -1, intervalMillis: -1}); + assert.deepEqual(config.validationErrors(), ['"Batch Size" should be at least 1', '"Batch Interval" should be at least 1']); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + assert.deepEqual(config.validationErrors(), ['"批次大小" 必須至少為 1', '"批次間隔" 必須至少為 1']); + }); + + test('the real scale model min-greater-than-max checks are localized without changing their boundaries', function(assert) { + for (let [type, Factory] of [['scaleService', ScaleService], ['scaleHost', ScaleHost]]) { + let subject = this.subject(type, {min: {type: 'int'}, max: {type: 'int'}}, {min: 3, max: 2}, Factory); + run(() => this.intl.setLocale(['en-us'])); + assert.deepEqual(subject.validationErrors(), ['"Minimum Scale" should be at most 2'], `${type} English`); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + assert.deepEqual(subject.validationErrors(), ['"最小數量" 必須最多為 2'], `${type} Traditional Chinese`); + for (let [min, max] of [[2, 2], [1, 2], [null, 2], [3, null]]) { + subject.setProperties({min, max}); + assert.deepEqual(subject.validationErrors(), [], `${type} unchanged boundary ${min}/${max}`); + } + } + }); + + test('model-specific label then direct model translation retain precedence over the form label', function(assert) { + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + this.intl.addTranslations('zh-tw', { + 'model.receiver.name.label': '專用名稱', 'model.receiver.driver': '專用類型', + 'model.scaleService.serviceId.label': '專用服務', + }); + let receiver = this.subject('receiver', {name: {type: 'string', required: true}, driver: {type: 'enum', required: true}}); + let config = this.subject('scaleService', {serviceId: {type: 'string', required: true}}); + assert.deepEqual(receiver.validationErrors(), ['"專用名稱" 必須設定', '"專用類型" 必須設定']); + assert.deepEqual(config.validationErrors(), ['"專用服務" 必須設定']); + }); + + test('unknown fields and same-named fields outside Receiver configs keep the existing English fallback', function(assert) { + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + for (let type of ['receiver', 'scaleService', 'scaleHost', 'serviceUpgrade']) { + let subject = this.subject(type, {unknownField: {type: 'string', required: true}}); + assert.deepEqual(subject.validationErrors(), ['"Unknown Field" 必須設定'], type); + } + let unrelated = this.subject('service', {serviceId: {type: 'string', required: true}}); + assert.deepEqual(unrelated.validationErrors(), ['"Service Id" 必須設定'], 'no global serviceId relabeling'); + }); + + test('valid nested inputs retain values, schemas and the same empty-error result', function(assert) { + for (let locale of ['en-us', 'zh-tw']) { + run(() => this.intl.setLocale([locale, 'en-us'])); + let config = this.subject('scaleService', { + serviceId: {type: 'string', required: true}, amount: {type: 'int', min: 1}, + }, {serviceId: 'test-service', amount: 1}); + let receiver = this.subject('receiver', { + name: {type: 'string', required: true}, scaleServiceConfig: {type: 'scaleService'}, + }, {name: 'test-receiver', scaleServiceConfig: config}); + assert.deepEqual(receiver.validationErrors(), [], locale); + assert.strictEqual(config.get('serviceId'), 'test-service'); + assert.strictEqual(config.get('amount'), 1); + assert.strictEqual(receiver.get('name'), 'test-receiver'); + } + }); +});