@projectcaluma/ember-form 11.0.0-beta.21 → 11.0.0-beta.22
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +11 -0
- package/addon/components/cf-field/input/files.hbs +35 -0
- package/addon/components/cf-field/input/files.js +113 -0
- package/addon/components/cf-field/input.js +2 -2
- package/addon/components/cf-field-value.hbs +5 -5
- package/addon/components/cf-field-value.js +6 -5
- package/addon/gql/fragments/field.graphql +3 -3
- package/addon/gql/mutations/save-document-files-answer.graphql +9 -0
- package/addon/gql/queries/{fileanswer-info.graphql → filesanswer-info.graphql} +4 -4
- package/addon/lib/field.js +5 -5
- package/app/components/cf-field/input/{file.js → files.js} +1 -1
- package/package.json +12 -12
- package/addon/components/cf-field/input/file.hbs +0 -32
- package/addon/components/cf-field/input/file.js +0 -89
- package/addon/gql/mutations/remove-answer.graphql +0 -7
- package/addon/gql/mutations/save-document-file-answer.graphql +0 -9
package/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
# [@projectcaluma/ember-form-v11.0.0-beta.22](https://github.com/projectcaluma/ember-caluma/compare/@projectcaluma/ember-form-v11.0.0-beta.21...@projectcaluma/ember-form-v11.0.0-beta.22) (2022-08-05)
|
2
|
+
|
3
|
+
|
4
|
+
* feat!: add multi file upload (#2040) ([c4fd004](https://github.com/projectcaluma/ember-caluma/commit/c4fd0049654b2d2e5ea62e5909a45d89cb888b40)), closes [#2040](https://github.com/projectcaluma/ember-caluma/issues/2040)
|
5
|
+
|
6
|
+
|
7
|
+
### BREAKING CHANGES
|
8
|
+
|
9
|
+
* This requires the caluma backend version v8.0.0-beta.12
|
10
|
+
or later.
|
11
|
+
|
1
12
|
# [@projectcaluma/ember-form-v11.0.0-beta.21](https://github.com/projectcaluma/ember-caluma/compare/@projectcaluma/ember-form-v11.0.0-beta.20...@projectcaluma/ember-form-v11.0.0-beta.21) (2022-06-09)
|
2
13
|
|
3
14
|
|
@@ -0,0 +1,35 @@
|
|
1
|
+
<div class="uk-flex-middle uk-grid-divider uk-grid-column-small" uk-grid>
|
2
|
+
<div uk-form-custom="target: true">
|
3
|
+
|
4
|
+
<input
|
5
|
+
type="file"
|
6
|
+
name={{@field.pk}}
|
7
|
+
id={{@field.pk}}
|
8
|
+
disabled={{@disabled}}
|
9
|
+
multiple
|
10
|
+
{{on "change" this.save}}
|
11
|
+
/>
|
12
|
+
<UkButton disabled={{@disabled}}>
|
13
|
+
{{t "caluma.form.selectFile"}}
|
14
|
+
</UkButton>
|
15
|
+
</div>
|
16
|
+
<ul class="uk-list uk-list-collapse" data-test-file-list={{@field.pk}}>
|
17
|
+
{{#each this.files as |file|}}
|
18
|
+
<li class="uk-text-justify uk-text-middle">
|
19
|
+
<UkButton
|
20
|
+
data-test-download-link={{file.id}}
|
21
|
+
@color="link"
|
22
|
+
@onClick={{fn this.download file.id}}
|
23
|
+
>
|
24
|
+
{{file.name}}
|
25
|
+
</UkButton>
|
26
|
+
<UkIcon
|
27
|
+
class="uk-icon-button uk-margin-small-left"
|
28
|
+
role="button"
|
29
|
+
@icon="trash"
|
30
|
+
{{on "click" (fn this.delete file.id)}}
|
31
|
+
/>
|
32
|
+
</li>
|
33
|
+
{{/each}}
|
34
|
+
</ul>
|
35
|
+
</div>
|
@@ -0,0 +1,113 @@
|
|
1
|
+
import { action } from "@ember/object";
|
2
|
+
import { inject as service } from "@ember/service";
|
3
|
+
import { macroCondition, isTesting } from "@embroider/macros";
|
4
|
+
import Component from "@glimmer/component";
|
5
|
+
import { queryManager } from "ember-apollo-client";
|
6
|
+
import fetch from "fetch";
|
7
|
+
|
8
|
+
import getFilesAnswerInfoQuery from "@projectcaluma/ember-form/gql/queries/filesanswer-info.graphql";
|
9
|
+
|
10
|
+
export default class CfFieldInputFilesComponent extends Component {
|
11
|
+
@service intl;
|
12
|
+
|
13
|
+
@queryManager apollo;
|
14
|
+
|
15
|
+
get files() {
|
16
|
+
return this.args.field?.answer?.value;
|
17
|
+
}
|
18
|
+
|
19
|
+
@action
|
20
|
+
async download(fileId) {
|
21
|
+
if (!fileId) {
|
22
|
+
return;
|
23
|
+
}
|
24
|
+
const answers = await this.apollo.query(
|
25
|
+
{
|
26
|
+
query: getFilesAnswerInfoQuery,
|
27
|
+
variables: { id: this.args.field.answer.raw.id },
|
28
|
+
fetchPolicy: "network-only",
|
29
|
+
},
|
30
|
+
"node.value"
|
31
|
+
);
|
32
|
+
const { downloadUrl } =
|
33
|
+
answers.find((file) =>
|
34
|
+
// the testing graph-ql setup does a base64 encoding of `__typename: fileID`
|
35
|
+
macroCondition(isTesting())
|
36
|
+
? file.id === fileId ||
|
37
|
+
atob(file.id).substring(file.__typename.length + 1) === fileId
|
38
|
+
: file.id === fileId
|
39
|
+
) ?? {};
|
40
|
+
if (downloadUrl) {
|
41
|
+
window.open(downloadUrl, "_blank");
|
42
|
+
}
|
43
|
+
}
|
44
|
+
|
45
|
+
@action
|
46
|
+
async save({ target }) {
|
47
|
+
// store the old list of files
|
48
|
+
// unwrap files from FileList construct
|
49
|
+
let newFiles = Array.from(target.files).map((file) => ({
|
50
|
+
name: file.name,
|
51
|
+
value: file,
|
52
|
+
}));
|
53
|
+
|
54
|
+
const fileList = [...(this.files || []), ...newFiles];
|
55
|
+
|
56
|
+
if (newFiles.length === 0) {
|
57
|
+
return;
|
58
|
+
}
|
59
|
+
|
60
|
+
// trigger save action for file list of old and new files with
|
61
|
+
// reduces properties to match gql format
|
62
|
+
const { filesValue: savedAnswerValue } = await this.args.onSave(
|
63
|
+
fileList.map(({ name, id }) => ({ name, id }))
|
64
|
+
);
|
65
|
+
|
66
|
+
try {
|
67
|
+
// iterate over list of new files and enrich with graphql answer values
|
68
|
+
newFiles = newFiles.map((file) => ({
|
69
|
+
...savedAnswerValue.find(
|
70
|
+
(value) =>
|
71
|
+
file.name === value.name &&
|
72
|
+
!fileList.find((file) => file.id === value.id)
|
73
|
+
),
|
74
|
+
value: file.value,
|
75
|
+
}));
|
76
|
+
|
77
|
+
const uploadFunction = async (file) => {
|
78
|
+
const response = await fetch(file.uploadUrl, {
|
79
|
+
method: "PUT",
|
80
|
+
body: file.value,
|
81
|
+
});
|
82
|
+
if (!response.ok) {
|
83
|
+
throw new Error();
|
84
|
+
}
|
85
|
+
return response;
|
86
|
+
};
|
87
|
+
|
88
|
+
// upload the actual file to data storage
|
89
|
+
await Promise.all(newFiles.map((file) => uploadFunction(file)));
|
90
|
+
|
91
|
+
this.args.field.answer.value = savedAnswerValue;
|
92
|
+
} catch (error) {
|
93
|
+
await this.args.onSave([]);
|
94
|
+
this.args.field._errors = [{ type: "uploadFailed" }];
|
95
|
+
} finally {
|
96
|
+
// eslint-disable-next-line require-atomic-updates
|
97
|
+
target.value = "";
|
98
|
+
}
|
99
|
+
}
|
100
|
+
|
101
|
+
@action
|
102
|
+
async delete(fileId) {
|
103
|
+
const remainingFiles = this.files
|
104
|
+
.filter((file) => file.id !== fileId)
|
105
|
+
.map(({ name, id }) => ({ name, id }));
|
106
|
+
|
107
|
+
try {
|
108
|
+
await this.args.onSave(remainingFiles);
|
109
|
+
} catch (error) {
|
110
|
+
this.args.field._errors = [{ type: "deleteFailed" }];
|
111
|
+
}
|
112
|
+
}
|
113
|
+
}
|
@@ -4,7 +4,7 @@ import Component from "@glimmer/component";
|
|
4
4
|
import ActionButtonComponent from "@projectcaluma/ember-form/components/cf-field/input/action-button";
|
5
5
|
import CheckboxComponent from "@projectcaluma/ember-form/components/cf-field/input/checkbox";
|
6
6
|
import DateComponent from "@projectcaluma/ember-form/components/cf-field/input/date";
|
7
|
-
import
|
7
|
+
import FilesComponent from "@projectcaluma/ember-form/components/cf-field/input/files";
|
8
8
|
import FloatComponent from "@projectcaluma/ember-form/components/cf-field/input/float";
|
9
9
|
import IntegerComponent from "@projectcaluma/ember-form/components/cf-field/input/integer";
|
10
10
|
import RadioComponent from "@projectcaluma/ember-form/components/cf-field/input/radio";
|
@@ -20,7 +20,7 @@ const COMPONENT_MAPPING = {
|
|
20
20
|
DateQuestion: DateComponent,
|
21
21
|
DynamicChoiceQuestion: RadioComponent,
|
22
22
|
DynamicMultipleChoiceQuestion: CheckboxComponent,
|
23
|
-
|
23
|
+
FilesQuestion: FilesComponent,
|
24
24
|
FloatQuestion: FloatComponent,
|
25
25
|
IntegerQuestion: IntegerComponent,
|
26
26
|
MultipleChoiceQuestion: CheckboxComponent,
|
@@ -11,14 +11,14 @@
|
|
11
11
|
month="2-digit"
|
12
12
|
year="numeric"
|
13
13
|
}}
|
14
|
-
{{else if (has-question-type @field.question "
|
15
|
-
{{#
|
14
|
+
{{else if (has-question-type @field.question "files")}}
|
15
|
+
{{#each @field.answer.value as |file|}}
|
16
16
|
<UkButton
|
17
17
|
@color="link"
|
18
|
-
@label={{
|
19
|
-
@onClick={{fn this.download
|
18
|
+
@label={{file.name}}
|
19
|
+
@onClick={{fn this.download file.id}}
|
20
20
|
/>
|
21
|
-
{{/
|
21
|
+
{{/each}}
|
22
22
|
{{else}}
|
23
23
|
{{@field.answer.value}}
|
24
24
|
{{/if}}
|
@@ -2,22 +2,23 @@ import { action } from "@ember/object";
|
|
2
2
|
import Component from "@glimmer/component";
|
3
3
|
import { queryManager } from "ember-apollo-client";
|
4
4
|
|
5
|
-
import
|
5
|
+
import getFilesAnswerInfoQuery from "@projectcaluma/ember-form/gql/queries/filesanswer-info.graphql";
|
6
6
|
|
7
7
|
export default class CfFieldValueComponent extends Component {
|
8
8
|
@queryManager apollo;
|
9
9
|
|
10
10
|
@action
|
11
11
|
async download(id) {
|
12
|
-
const
|
12
|
+
const files = await this.apollo.query(
|
13
13
|
{
|
14
|
-
query:
|
15
|
-
variables: { id },
|
14
|
+
query: getFilesAnswerInfoQuery,
|
15
|
+
variables: { id: this.args.field.answer.raw.id },
|
16
16
|
fetchPolicy: "network-only",
|
17
17
|
},
|
18
|
-
"node.
|
18
|
+
"node.value"
|
19
19
|
);
|
20
20
|
|
21
|
+
const { downloadUrl } = files?.find((file) => file.id === id);
|
21
22
|
if (downloadUrl) {
|
22
23
|
window.open(downloadUrl, "_blank");
|
23
24
|
}
|
@@ -117,7 +117,7 @@ fragment SimpleQuestion on Question {
|
|
117
117
|
calcExpression
|
118
118
|
hintText
|
119
119
|
}
|
120
|
-
... on
|
120
|
+
... on FilesQuestion {
|
121
121
|
hintText
|
122
122
|
}
|
123
123
|
... on ActionButtonQuestion {
|
@@ -234,8 +234,8 @@ fragment SimpleAnswer on Answer {
|
|
234
234
|
... on ListAnswer {
|
235
235
|
listValue: value
|
236
236
|
}
|
237
|
-
... on
|
238
|
-
|
237
|
+
... on FilesAnswer {
|
238
|
+
filesValue: value {
|
239
239
|
id
|
240
240
|
uploadUrl
|
241
241
|
downloadUrl
|
package/addon/lib/field.js
CHANGED
@@ -13,7 +13,7 @@ import { cached } from "tracked-toolbox";
|
|
13
13
|
|
14
14
|
import { decodeId } from "@projectcaluma/ember-core/helpers/decode-id";
|
15
15
|
import saveDocumentDateAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-date-answer.graphql";
|
16
|
-
import
|
16
|
+
import saveDocumentFilesAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-files-answer.graphql";
|
17
17
|
import saveDocumentFloatAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-float-answer.graphql";
|
18
18
|
import saveDocumentIntegerAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-integer-answer.graphql";
|
19
19
|
import saveDocumentListAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-list-answer.graphql";
|
@@ -34,7 +34,7 @@ export const TYPE_MAP = {
|
|
34
34
|
DynamicChoiceQuestion: "StringAnswer",
|
35
35
|
TableQuestion: "TableAnswer",
|
36
36
|
FormQuestion: null,
|
37
|
-
|
37
|
+
FilesQuestion: "FilesAnswer",
|
38
38
|
StaticQuestion: null,
|
39
39
|
DateQuestion: "DateAnswer",
|
40
40
|
};
|
@@ -44,7 +44,7 @@ const MUTATION_MAP = {
|
|
44
44
|
IntegerAnswer: saveDocumentIntegerAnswerMutation,
|
45
45
|
StringAnswer: saveDocumentStringAnswerMutation,
|
46
46
|
ListAnswer: saveDocumentListAnswerMutation,
|
47
|
-
|
47
|
+
FilesAnswer: saveDocumentFilesAnswerMutation,
|
48
48
|
DateAnswer: saveDocumentDateAnswerMutation,
|
49
49
|
TableAnswer: saveDocumentTableAnswerMutation,
|
50
50
|
};
|
@@ -810,11 +810,11 @@ export default class Field extends Base {
|
|
810
810
|
/**
|
811
811
|
* Dummy method for the validation of file uploads.
|
812
812
|
*
|
813
|
-
* @method
|
813
|
+
* @method _validateFilesQuestion
|
814
814
|
* @return {Boolean} Always returns true
|
815
815
|
* @private
|
816
816
|
*/
|
817
|
-
|
817
|
+
_validateFilesQuestion() {
|
818
818
|
return true;
|
819
819
|
}
|
820
820
|
|
@@ -1 +1 @@
|
|
1
|
-
export { default } from "@projectcaluma/ember-form/components/cf-field/input/
|
1
|
+
export { default } from "@projectcaluma/ember-form/components/cf-field/input/files";
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@projectcaluma/ember-form",
|
3
|
-
"version": "11.0.0-beta.
|
3
|
+
"version": "11.0.0-beta.22",
|
4
4
|
"description": "Ember addon for rendering Caluma forms.",
|
5
5
|
"keywords": [
|
6
6
|
"ember-addon"
|
@@ -15,16 +15,16 @@
|
|
15
15
|
},
|
16
16
|
"dependencies": {
|
17
17
|
"@ember/string": "^3.0.0",
|
18
|
-
"@embroider/macros": "^1.
|
19
|
-
"@embroider/util": "^1.
|
18
|
+
"@embroider/macros": "^1.8.3",
|
19
|
+
"@embroider/util": "^1.8.3",
|
20
20
|
"@glimmer/component": "^1.1.2",
|
21
21
|
"@glimmer/tracking": "^1.1.2",
|
22
|
-
"@projectcaluma/ember-core": "^11.0.0-beta.
|
23
|
-
"ember-apollo-client": "
|
22
|
+
"@projectcaluma/ember-core": "^11.0.0-beta.9",
|
23
|
+
"ember-apollo-client": "~4.0.2",
|
24
24
|
"ember-auto-import": "^2.4.2",
|
25
25
|
"ember-autoresize-modifier": "^0.5.0",
|
26
26
|
"ember-cli-babel": "^7.26.11",
|
27
|
-
"ember-cli-htmlbars": "^6.0
|
27
|
+
"ember-cli-htmlbars": "^6.1.0",
|
28
28
|
"ember-cli-showdown": "^6.0.1",
|
29
29
|
"ember-composable-helpers": "^5.0.0",
|
30
30
|
"ember-concurrency": "^2.2.1",
|
@@ -34,21 +34,21 @@
|
|
34
34
|
"ember-math-helpers": "^2.18.2",
|
35
35
|
"ember-pikaday": "^4.0.0",
|
36
36
|
"ember-power-select": "^5.0.4",
|
37
|
-
"ember-resources": "^
|
37
|
+
"ember-resources": "^5.1.1",
|
38
38
|
"ember-uikit": "^5.1.3",
|
39
39
|
"ember-validators": "^4.1.2",
|
40
40
|
"graphql": "^15.8.0",
|
41
41
|
"jexl": "^2.3.0",
|
42
42
|
"lodash.isequal": "^4.5.0",
|
43
|
-
"luxon": "^
|
43
|
+
"luxon": "^3.0.1",
|
44
44
|
"tracked-toolbox": "^1.2.3"
|
45
45
|
},
|
46
46
|
"devDependencies": {
|
47
47
|
"@ember/optional-features": "2.0.0",
|
48
48
|
"@ember/test-helpers": "2.8.1",
|
49
|
-
"@embroider/test-setup": "1.
|
50
|
-
"@faker-js/faker": "7.
|
51
|
-
"@projectcaluma/ember-testing": "11.0.0-beta.
|
49
|
+
"@embroider/test-setup": "1.8.3",
|
50
|
+
"@faker-js/faker": "7.3.0",
|
51
|
+
"@projectcaluma/ember-testing": "11.0.0-beta.10",
|
52
52
|
"@projectcaluma/ember-workflow": "^11.0.0-beta.7",
|
53
53
|
"broccoli-asset-rev": "3.0.0",
|
54
54
|
"ember-cli": "3.28.5",
|
@@ -73,7 +73,7 @@
|
|
73
73
|
"qunit": "2.19.1",
|
74
74
|
"qunit-dom": "2.0.0",
|
75
75
|
"uuid": "8.3.2",
|
76
|
-
"webpack": "5.
|
76
|
+
"webpack": "5.74.0"
|
77
77
|
},
|
78
78
|
"peerDependencies": {
|
79
79
|
"@projectcaluma/ember-workflow": "^11.0.0-beta.7"
|
@@ -1,32 +0,0 @@
|
|
1
|
-
<div class="uk-flex-middle uk-grid-divider uk-grid-column-small" uk-grid>
|
2
|
-
<div uk-form-custom="target: true">
|
3
|
-
|
4
|
-
<input
|
5
|
-
type="file"
|
6
|
-
name={{@field.pk}}
|
7
|
-
id={{@field.pk}}
|
8
|
-
disabled={{@disabled}}
|
9
|
-
{{on "change" this.save}}
|
10
|
-
/>
|
11
|
-
<UkButton disabled={{@disabled}}>
|
12
|
-
{{t "caluma.form.selectFile"}}
|
13
|
-
</UkButton>
|
14
|
-
</div>
|
15
|
-
{{#if (and this.downloadUrl this.downloadName)}}
|
16
|
-
<div>
|
17
|
-
<UkButton
|
18
|
-
data-test-download-link
|
19
|
-
@color="link"
|
20
|
-
@onClick={{this.download}}
|
21
|
-
>
|
22
|
-
{{this.downloadName}}
|
23
|
-
</UkButton>
|
24
|
-
<UkIcon
|
25
|
-
class="uk-icon-button uk-margin-small-left"
|
26
|
-
role="button"
|
27
|
-
@icon="trash"
|
28
|
-
{{on "click" this.delete}}
|
29
|
-
/>
|
30
|
-
</div>
|
31
|
-
{{/if}}
|
32
|
-
</div>
|
@@ -1,89 +0,0 @@
|
|
1
|
-
import { action } from "@ember/object";
|
2
|
-
import { inject as service } from "@ember/service";
|
3
|
-
import Component from "@glimmer/component";
|
4
|
-
import { queryManager } from "ember-apollo-client";
|
5
|
-
import fetch from "fetch";
|
6
|
-
|
7
|
-
import removeAnswerMutation from "@projectcaluma/ember-form/gql/mutations/remove-answer.graphql";
|
8
|
-
import getFileAnswerInfoQuery from "@projectcaluma/ember-form/gql/queries/fileanswer-info.graphql";
|
9
|
-
|
10
|
-
export default class CfFieldInputFileComponent extends Component {
|
11
|
-
@service intl;
|
12
|
-
|
13
|
-
@queryManager apollo;
|
14
|
-
|
15
|
-
get downloadUrl() {
|
16
|
-
return this.args.field?.answer?.value?.downloadUrl;
|
17
|
-
}
|
18
|
-
|
19
|
-
get downloadName() {
|
20
|
-
return this.args.field?.answer?.value?.name;
|
21
|
-
}
|
22
|
-
|
23
|
-
@action
|
24
|
-
async download() {
|
25
|
-
const { downloadUrl } = await this.apollo.query(
|
26
|
-
{
|
27
|
-
query: getFileAnswerInfoQuery,
|
28
|
-
variables: { id: this.args.field.answer.raw.id },
|
29
|
-
fetchPolicy: "network-only",
|
30
|
-
},
|
31
|
-
"node.fileValue"
|
32
|
-
);
|
33
|
-
|
34
|
-
if (downloadUrl) {
|
35
|
-
window.open(downloadUrl, "_blank");
|
36
|
-
}
|
37
|
-
}
|
38
|
-
|
39
|
-
@action
|
40
|
-
async save({ target }) {
|
41
|
-
const file = target.files[0];
|
42
|
-
|
43
|
-
if (!file) {
|
44
|
-
return;
|
45
|
-
}
|
46
|
-
|
47
|
-
const { fileValue } = await this.args.onSave(file.name);
|
48
|
-
|
49
|
-
try {
|
50
|
-
const response = await fetch(fileValue.uploadUrl, {
|
51
|
-
method: "PUT",
|
52
|
-
body: file,
|
53
|
-
});
|
54
|
-
|
55
|
-
if (!response.ok) {
|
56
|
-
throw new Error();
|
57
|
-
}
|
58
|
-
|
59
|
-
this.args.field.answer.value = {
|
60
|
-
name: file.name,
|
61
|
-
downloadUrl: fileValue.downloadUrl,
|
62
|
-
};
|
63
|
-
} catch (error) {
|
64
|
-
await this.args.onSave(null);
|
65
|
-
this.args.field._errors = [{ type: "uploadFailed" }];
|
66
|
-
} finally {
|
67
|
-
// eslint-disable-next-line require-atomic-updates
|
68
|
-
target.value = "";
|
69
|
-
}
|
70
|
-
}
|
71
|
-
|
72
|
-
@action
|
73
|
-
async delete() {
|
74
|
-
try {
|
75
|
-
await this.apollo.mutate({
|
76
|
-
mutation: removeAnswerMutation,
|
77
|
-
variables: {
|
78
|
-
input: {
|
79
|
-
answer: this.args.field.answer.uuid,
|
80
|
-
},
|
81
|
-
},
|
82
|
-
});
|
83
|
-
|
84
|
-
await this.args.onSave(null);
|
85
|
-
} catch (error) {
|
86
|
-
this.args.field._errors = [{ type: "deleteFailed" }];
|
87
|
-
}
|
88
|
-
}
|
89
|
-
}
|