fix(cropper): render reason checkboxes, gate the Others field, add cropped preview

Three defects from the Carbon rewrite, found while fixing the review panel:

- reason checkboxes were invisible: Carbon's .cds--checkbox input is
  visually clipped and needs the .cds--form-item/.cds--checkbox-wrapper +
  .cds--checkbox-label markup to draw the box
- 'Others (please specify)' now appears only when 'Others not specified.'
  is ticked, and clears when unticked (hidden text must not be submitted);
  the textarea gets Carbon's __wrapper so it has a proper height
- new 'Cropped preview' between the original and cleaned previews, so the
  operator sees what will be uploaded; refreshed on pointer release over the
  pad and after load/clean
- REGRESSION: Vue.use(Croppa) had been dropped — the crop canvas never
  rendered, and saving only worked because the cleaned image bypassed
  generateDataUrl. Restored.
- REGRESSION: the OTP field bound v-model="otpSave" while the data property
  is "otp", so Vue's render threw ReferenceError and froze the panel as
  soon as accept was clicked (the OTP could never be entered). Bound to otp;
  the croppa instance is now reached via $refs.croppa (the v-model object is
  not always the live instance)
- after a successful save the review panel stayed open: reset() recreated an
  empty message object, which the template treats as truthy. message is null
  when idle now.

45 tests pass; save round-trip and reject/Others flow verified in the browser.
This commit is contained in:
2026-10-01 04:26:26 +01:00
parent 2e4a51d018
commit cb5b151988
2 changed files with 65 additions and 15 deletions
+45 -6
View File
@@ -3,6 +3,7 @@
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
/* global Vue, axios, Croppa, customElements */
/* window.__SESSION__ is injected server-side by /cropper */
Vue.use(Croppa); // registers the <croppa> component — without this the crop UI never renders
Vue.config.ignoredElements = [/^cds-/];
(function () {
@@ -15,14 +16,13 @@ Vue.config.ignoredElements = [/^cds-/];
data: {
loading: true,
session: null,
message: {
message: { caption: null, file_url: null, domain: '' },
},
message: null, // set once a picture is loaded; null hides the review panel
agent: null,
process: null,
reviewed: false,
accepted: false,
cleanedImage: null,
cropPreview: '',
requiresCleaning: '',
otp: '',
reviewOptions: [
@@ -62,6 +62,19 @@ Vue.config.ignoredElements = [/^cds-/];
canSave: function () {
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
},
otherOptionSelected: function () {
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
},
croppaInstance: function () {
// the v-model object is not always the live instance; the ref is
return this.$refs.croppa || this.myCroppa;
},
},
watch: {
otherOptionSelected: function (selected) {
if (!selected) this.otherReviewOption = ''; // hidden field must not contribute
},
},
created: function () {
@@ -96,8 +109,31 @@ Vue.config.ignoredElements = [/^cds-/];
this.toasts = this.toasts.filter(function (t) { return t.id !== id; });
},
/**
* Current crop result — the operator should see what will be uploaded.
* croppa loads its source image asynchronously, so retry briefly until
* the canvas can produce a URL.
*/
refreshCropPreview: function (attempt) {
var self = this;
var tries = attempt || 0;
this.$nextTick(function () {
try {
var croppa = self.croppaInstance;
self.cropPreview =
croppa && croppa.generateDataUrl ? croppa.generateDataUrl('image/jpeg') || '' : '';
} catch (error) {
self.cropPreview = '';
}
if (!self.cropPreview && tries < 12) {
setTimeout(function () { self.refreshCropPreview(tries + 1); }, 200);
}
});
},
reset: function () {
this.message = { message: { caption: null, file_url: null, domain: '' } };
this.cropPreview = '';
this.message = null;
this.agent = null;
this.process = null;
this.reviewed = false;
@@ -137,6 +173,8 @@ Vue.config.ignoredElements = [/^cds-/];
}
this.message.message.file_url = image.data.data;
this.cleanedImage = null;
this.cropPreview = '';
this.$nextTick(this.refreshCropPreview); // croppa needs the image first
this.agent = null;
this.process = this.message.message.process || null;
if (this.message.message.upload_id) {
@@ -156,7 +194,7 @@ Vue.config.ignoredElements = [/^cds-/];
var self = this;
this.busy.clean = true;
try {
var dataUrl = this.myCroppa.generateDataUrl('image/jpeg');
var dataUrl = this.croppaInstance.generateDataUrl('image/jpeg');
var response = await fetch('/cropper/api/removebg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -174,6 +212,7 @@ Vue.config.ignoredElements = [/^cds-/];
var fr = new FileReader();
fr.onload = function () { self.cleanedImage = fr.result; };
fr.readAsDataURL(blob);
this.refreshCropPreview();
} catch ({ message }) {
this.toast('error', 'Image Cleanup Failed', message);
} finally {
@@ -252,7 +291,7 @@ Vue.config.ignoredElements = [/^cds-/];
var { data } = await axios.post(
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
{
pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'),
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
phone: this.message.message.caption,
message: this.message,
requiresCleaning: this.requiresCleaning !== 'No',