Skip to content

Commit

Permalink
Merge pull request #7461 from surveyjs/issue/7443-Signature-Pad---Upl…
Browse files Browse the repository at this point in the history
…oading-to-a-Storage---Unexpected-behavior-on-an-attempt-to-submit-a-survey-without-sending-a-signature-to-the-server-first

Issue/7443-Signature-Pad---Uploading-to-a-Storage---Unexpected-behavior-on-an-attempt-to-submit-a-survey-without-sending-a-signature-to-the-server-first
  • Loading branch information
andrewtelnov authored Dec 4, 2023
2 parents bc316ee + 97cffd8 commit 5cbfb38
Show file tree
Hide file tree
Showing 12 changed files with 155 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div [class]="model.cssClasses.placeholder" [visible]="model.needShowPlaceholder()" [model]="$any(model).locPlaceholder" sv-ng-string></div>
<div>
<img *ngIf="!!model.backgroundImage" [src]="model.backgroundImage" [style.width]="model.renderedCanvasWidth" [class]="model.cssClasses.backgroundImage">
<canvas tabindex="0" [class]="model.cssClasses.canvas" (blur)="model.onBlur()"></canvas>
<canvas tabindex="0" [class]="model.cssClasses.canvas" (blur)="model.onBlur($event)"></canvas>
</div>
<div [class]="model.cssClasses.controls" *ngIf="model.canShowClearButton">
<button
Expand Down
2 changes: 1 addition & 1 deletion packages/survey-vue3-ui/src/Signaturepad.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
width: question.renderedCanvasWidth,
}"
/>
<canvas tabindex="0" :class="question.cssClasses.canvas" @blur="question.onBlur()"></canvas>
<canvas tabindex="0" :class="question.cssClasses.canvas" @blur="question.onBlur"></canvas>
</div>
<div
:class="question.cssClasses.controls"
Expand Down
3 changes: 3 additions & 0 deletions src/knockout/koquestion_signaturepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export class QuestionSignaturePad extends QuestionSignaturePadModel {
constructor(name: string) {
super(name);
}
public koOnBlur(data: any, event: any) {
return this.onBlur(event);
}
protected onBaseCreating() {
super.onBaseCreating();
this._implementor = new QuestionImplementor(this);
Expand Down
2 changes: 1 addition & 1 deletion src/knockout/templates/question-signaturepad.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<!-- ko if: question.backgroundImage -->
<img data-bind="attr: { src: question.backgroundImage}, style: { width: question.renderedCanvasWidth }, css: question.cssClasses.backgroundImage">
<!-- /ko -->
<canvas tabindex='0' data-bind="css: question.cssClasses.canvas, event: { blur: question.onBlur }" ></canvas>
<canvas tabindex='0' data-bind="css: question.cssClasses.canvas, event: { blur: question.koOnBlur }" ></canvas>
</div>
<!-- ko if: question.canShowClearButton -->
<div data-bind="css: question.cssClasses.controls">
Expand Down
7 changes: 6 additions & 1 deletion src/question_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { LocalizableString } from "./localizablestring";
import { settings } from "./settings";
import { getRenderedSize } from "./utils/utils";

export function dataUrl2File(dataUrl: string, fileName: string, type: string) {
const str = atob(dataUrl.split(",")[1]);
const buffer = new Uint8Array(str.split("").map(c => c.charCodeAt(0))).buffer;
return new File([buffer], fileName, { type: type });
}
export class QuestionFileModelBase extends Question {
@property() public isUploading: boolean = false;
@property({ defaultValue: "empty" }) currentState: string;
Expand Down Expand Up @@ -55,7 +60,7 @@ export class QuestionFileModelBase extends Question {
return this.isUploading && this.isDefaultV2Theme;
}
/**
* Specifies whether to store file content as text in `SurveyModel`'s [`data`](https://surveyjs.io/form-library/documentation/surveymodel#data) property.
* Specifies whether to store file or signature content as text in `SurveyModel`'s [`data`](https://surveyjs.io/form-library/documentation/surveymodel#data) property.
*
* If you disable this property, implement `SurveyModel`'s [`onUploadFiles`](https://surveyjs.io/form-library/documentation/surveymodel#onUploadFiles) event handler to specify how to store file content.
*/
Expand Down
16 changes: 8 additions & 8 deletions src/question_signaturepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CssClassBuilder } from "./utils/cssClassBuilder";
import { SurveyModel } from "./survey";
import { ConsoleWarnings } from "./console-warnings";
import { ITheme } from "./themes";
import { QuestionFileModelBase } from "./question_file";
import { dataUrl2File, QuestionFileModelBase } from "./question_file";

var defaultWidth = 300;
var defaultHeight = 200;
Expand Down Expand Up @@ -63,6 +63,7 @@ export class QuestionSignaturePadModel extends QuestionFileModelBase {
public afterRenderQuestionElement(el: HTMLElement) {
if (!!el) {
this.initSignaturePad(el);
this.element = el;
}
super.afterRenderQuestionElement(el);
}
Expand All @@ -77,6 +78,7 @@ export class QuestionSignaturePadModel extends QuestionFileModelBase {
}
}
private canvas: any;
private element: any;
private scale: number;
private valueIsUpdatingInternally: boolean = false;
@property({ defaultValue: false }) valueWasChangedFromLastUpload: boolean;
Expand Down Expand Up @@ -351,15 +353,13 @@ export class QuestionSignaturePadModel extends QuestionFileModelBase {
*/
@property({ localizable: { defaultStr: "signaturePlaceHolder" } }) placeholder: string;

public onBlur(): void {
public onBlur = (event: any): void => {
if (!this.storeDataAsText) {
setTimeout(() => {
if (!this.element.contains(event.relatedTarget)) {
if (!this.valueWasChangedFromLastUpload) return;
fetch(this.signaturePad.toDataURL(this.getFormat())).then(res => res.blob()).then((blob: Blob) => {
this.uploadFiles([new File([blob], this.name + "." + correctFormatData(this.dataFormat), { type: this.getFormat() })]);
this.valueWasChangedFromLastUpload = false;
});
}, 100);
this.uploadFiles([dataUrl2File(this.signaturePad.toDataURL(this.getFormat()), this.name + "." + correctFormatData(this.dataFormat), this.getFormat())]);
this.valueWasChangedFromLastUpload = false;
}
}
}
protected uploadResultItemToValue(r: any) {
Expand Down
2 changes: 1 addition & 1 deletion src/react/signaturepad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class SurveyQuestionSignaturePad extends SurveyQuestionElementBase {
</div>
<div>
{this.renderBackgroundImage()}
<canvas tabIndex={0} className={this.question.cssClasses.canvas} onBlur={() => this.question.onBlur()}></canvas>
<canvas tabIndex={0} className={this.question.cssClasses.canvas} onBlur={this.question.onBlur}></canvas>
</div>
{clearButton}
{loadingIndicator}
Expand Down
2 changes: 1 addition & 1 deletion src/survey-events-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface QuestionEventMixin {
}
export interface FileQuestionEventMixin {
/**
* A File Upload question instance for which the event is raised.
* A File Upload or Signature Pad question instance for which the event is raised.
*/
question: QuestionFileModel | QuestionSignaturePadModel;
}
Expand Down
28 changes: 18 additions & 10 deletions src/survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { PopupModel } from "./popup";
import { Cover } from "./header";
import { surveyTimerFunctions } from "./surveytimer";
import { QuestionSignaturePadModel } from "./question_signaturepad";
import { SurveyTaskManagerModel } from "./surveyTaskManager";

/**
* The `SurveyModel` object contains properties and methods that allow you to control the survey and access its elements.
Expand Down Expand Up @@ -440,7 +441,7 @@ export class SurveyModel extends SurveyElementCore
*/
public onGetResult: EventBase<SurveyModel, GetResultEvent> = this.addEvent<SurveyModel, GetResultEvent>();
/**
* An event that is raised when a File Upload question starts to upload a file. Applies only if [`storeDataAsText`](https://surveyjs.io/form-library/documentation/api-reference/file-model#storeDataAsText) is `false`. Use this event to upload files to your server.
* An event that is raised when a File Upload or Signature Pad question starts to upload a file. Applies only if [`storeDataAsText`](https://surveyjs.io/form-library/documentation/api-reference/file-model#storeDataAsText) is `false`. Use this event to upload files to your server.
*
* For information on event handler parameters, refer to descriptions within the interface.
*
Expand All @@ -462,7 +463,7 @@ export class SurveyModel extends SurveyElementCore
*/
public onDownloadFile: EventBase<SurveyModel, DownloadFileEvent> = this.addEvent<SurveyModel, DownloadFileEvent>();
/**
* An event that is raised when users clear files in a [File Upload](https://surveyjs.io/form-library/documentation/api-reference/file-model) question. Use this event to delete files from your server.
* An event that is raised when users clear files in a [File Upload](https://surveyjs.io/form-library/documentation/api-reference/file-model) question or clear signature in a [Signature Pad](https://surveyjs.io/form-library/documentation/api-reference/signature-pad-model) question. Use this event to delete files from your server.
*
* For information on event handler parameters, refer to descriptions within the interface.
*
Expand Down Expand Up @@ -5047,8 +5048,8 @@ export class SurveyModel extends SurveyElementCore
* }
* );
* ```
* @param question A [File Upload question instance](https://surveyjs.io/form-library/documentation/api-reference/file-model).
* @param name The File Upload question's [`name`](https://surveyjs.io/form-library/documentation/api-reference/file-model#name).
* @param question A [File Upload question instance](https://surveyjs.io/form-library/documentation/api-reference/file-model) or [Signature Pad question instance](https://surveyjs.io/form-library/documentation/api-reference/signature-pad-model).
* @param name The File Upload question's [`name`](https://surveyjs.io/form-library/documentation/api-reference/file-model#name) or Signature Pad question's [`name`](https://surveyjs.io/form-library/documentation/api-reference/signature-pad-model#name).
* @param files An array of JavaScript <a href="https://developer.mozilla.org/en-US/docs/Web/API/File" target="_blank">File</a> objects that represent files to upload.
* @param callback A callback function that allows you to access successfully uploaded files as the first argument. If any files fail to upload, the second argument contains an array of error messages.
* @see onUploadFiles
Expand All @@ -5063,11 +5064,16 @@ export class SurveyModel extends SurveyElementCore
if (this.onUploadFiles.isEmpty) {
callback("error", this.getLocString("noUploadFilesHandler"));
} else {
this.onUploadFiles.fire(this, {
question: question,
name: name,
files: files || [],
callback: callback,
this.taskManager.runTask("file", (done) => {
this.onUploadFiles.fire(this, {
question: question,
name: name,
files: files || [],
callback: (status, data) => {
callback(status, data);
done();
},
});
});
}
if (this.surveyPostId) {
Expand Down Expand Up @@ -6047,7 +6053,7 @@ export class SurveyModel extends SurveyElementCore
mouseDown: () => this.navigationMouseDown(),
},
locTitle: this.locCompleteText,
action: () => this.completeLastPage(),
action: () => this.taskManager.waitAndExecute(() => this.completeLastPage()),
component: defaultComponent
});
this.updateNavigationItemCssCallback = () => {
Expand Down Expand Up @@ -7433,6 +7439,8 @@ export class SurveyModel extends SurveyElementCore
this.getAllQuestions().forEach(q => q.themeChanged(theme));
}

private taskManager: SurveyTaskManagerModel = new SurveyTaskManagerModel();

/**
* Disposes of the survey model.
*
Expand Down
52 changes: 52 additions & 0 deletions src/surveyTaskManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ISurvey } from "./base-interfaces";
import { Base, EventBase } from "./base";
import { SurveyTimer } from "./surveytimer";
import { property } from "./jsonobject";
import { PageModel } from "./page";
import { SurveyModel } from "./survey";
import { CssClassBuilder } from "./utils/cssClassBuilder";

class SurveyTaskModel {
private timestamp: Date;
constructor(public type: string) {
this.timestamp = new Date();
}
}

export class SurveyTaskManagerModel extends Base {
private taskList: SurveyTaskModel[] = [];
constructor() {
super();
}

private onAllTasksCompleted: EventBase<SurveyTaskManagerModel> = this.addEvent<SurveyTaskManagerModel>();
//@property() text: string;
@property({ defaultValue: false }) hasActiveTasks: boolean;

public runTask(type: string, func: (done: any) => void): SurveyTaskModel {
const task = new SurveyTaskModel(type);
this.taskList.push(task);
this.hasActiveTasks = true;
func(() => this.taskFinished(task));
return task;
}

public waitAndExecute(action: any) {
if(!this.hasActiveTasks) {
action();
return;
}
this.onAllTasksCompleted.add(()=> { action(); });
}

private taskFinished(task: SurveyTaskModel) {
const index = this.taskList.indexOf(task);
if (index > -1) {
this.taskList.splice(index, 1);
}
if(this.hasActiveTasks && this.taskList.length == 0) {
this.hasActiveTasks = false;
this.onAllTasksCompleted.fire(this, {});
}
}
}
2 changes: 1 addition & 1 deletion src/vue/signaturepad.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
v-bind:style="{
width: question.renderedCanvasWidth,
}">
<canvas tabindex="0" :class="question.cssClasses.canvas" @blur="question.onBlur()"></canvas>
<canvas tabindex="0" :class="question.cssClasses.canvas" @blur="question.onBlur"></canvas>
</div>
<div :class="question.cssClasses.controls" v-if="question.canShowClearButton">
<button
Expand Down
73 changes: 62 additions & 11 deletions tests/question_signaturepadtests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,43 +398,94 @@ QUnit.test("Question Signature upload files", function (assert) {
var q1: QuestionSignaturePadModel = <any>survey.getQuestionByName("signature");
var done = assert.async();

var eventFired;
var fileLoaded;
var fileName;
var fileType;
var fileContent;
survey.onUploadFiles.add((survey, options) => {
let file = options.files[0];
let fileReader = new FileReader();
eventFired = true;
fileReader.onload = (e) => {
fileLoaded = true;
fileName = file.name;
fileType = file.type;
fileContent = fileReader.result;
setTimeout(
() =>
options.callback(
"success",
options.files.map((file) => {
return { file: file, content: file.name + "_url" };
})
),
2
);
};
fileReader.readAsDataURL(file);
setTimeout(
() =>
options.callback(
"success",
options.files.map((file) => {
return { file: file, content: file.name + "_url" };
})
),
2
);
});

const el = document.createElement("div");
el.append(document.createElement("canvas"));
q1.afterRenderQuestionElement(el);
q1["signaturePad"].fromData([{ "penColor": "rgba(25, 179, 148, 1)", "dotSize": 0, "minWidth": 0.5, "maxWidth": 2.5, "velocityFilterWeight": 0.7, "compositeOperation": "source-over", "points": [{ "time": 1701152337021, "x": 9, "y": 11, "pressure": 0.5 }] }, { "penColor": "rgba(25, 179, 148, 1)", "dotSize": 0, "minWidth": 0.5, "maxWidth": 2.5, "velocityFilterWeight": 0.7, "compositeOperation": "source-over", "points": [{ "time": 1701152337856, "x": 15, "y": 18, "pressure": 0.5 }] }]);
q1.valueWasChangedFromLastUpload = true;
q1.onBlur();
q1.onBlur({ target: null } as any);

survey.onValueChanged.add((survey, options) => {
assert.equal(q1.value, "signature.svg_url");
assert.ok(eventFired);
assert.ok(fileLoaded);

assert.equal(fileType, "image/svg+xml");
assert.equal(fileName, "signature.svg");
assert.equal(fileContent, "data:image/svg+xml;base64," + btoa('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 300 200" width="300" height="200"><circle r="1.5" cx="9" cy="11" fill="rgba(25, 179, 148, 1)"></circle><circle r="1.5" cx="15" cy="18" fill="rgba(25, 179, 148, 1)"></circle></svg>'));
done();
});
});

QUnit.test("Question Signature upload files - and complete", function (assert) {
var json = {
questions: [
{
type: "signaturepad",
name: "signature",
storeDataAsText: false,
},
],
};

var survey = new SurveyModel(json);
var q1: QuestionSignaturePadModel = <any>survey.getQuestionByName("signature");
var done = assert.async();
var filesLoaded = false;
survey.onUploadFiles.add((survey, options) => {
setTimeout(
() => {
filesLoaded = true;
options.callback(
"success",
options.files.map((file) => {
return { file: file, content: file.name + "_url" };
})
);
},
2
);
});

const el = document.createElement("div");
el.append(document.createElement("canvas"));
q1.afterRenderQuestionElement(el);
q1.valueWasChangedFromLastUpload = true;
survey.onComplete.add((survey, options) => {
assert.ok(filesLoaded);
done();
});

q1.onBlur({ target: null } as any);
survey.navigationBar.getActionById("sv-nav-complete").action();

});

0 comments on commit 5cbfb38

Please sign in to comment.