neo.mjs 5.15.4 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,9 +20,9 @@ class ServiceWorker extends ServiceBase {
20
20
  */
21
21
  singleton: true,
22
22
  /**
23
- * @member {String} version='5.15.4'
23
+ * @member {String} version='5.16.0'
24
24
  */
25
- version: '5.15.4'
25
+ version: '5.16.0'
26
26
  }
27
27
 
28
28
  /**
@@ -1,9 +1,11 @@
1
- import fs from 'fs-extra';
2
- import helper from 'neo-jsdoc-x/src/lib/helper.js';
3
- import jsdocx from 'neo-jsdoc-x';
4
- import path from 'path';
1
+ import fs from 'fs-extra';
2
+ import helper from 'neo-jsdoc-x/src/lib/helper.js';
3
+ import jsdocx from 'neo-jsdoc-x';
4
+ import path from 'path';
5
+ import showdown from 'showdown';
5
6
 
6
7
  const __dirname = path.resolve(),
8
+ markdown = new showdown.Converter(),
7
9
  cwd = process.cwd(),
8
10
  requireJson = path => JSON.parse(fs.readFileSync((path))),
9
11
  packageJson = requireJson(path.resolve(cwd, 'package.json')),
@@ -324,12 +326,12 @@ jsdocx.parse(options)
324
326
  namespace.classData.push(item);
325
327
 
326
328
  if (item.description) {
327
- item.description = item.description.replace(/\n/g, "<br />");
329
+ item.description = markdown.makeHtml(item.description)
328
330
  }
329
331
 
330
332
  item.params?.forEach(param => {
331
333
  if (param.description) {
332
- param.description = param.description.replace(/\n/g, "<br />");
334
+ param.description = markdown.makeHtml(param.description)
333
335
  }
334
336
  });
335
337
 
@@ -38,9 +38,9 @@ class HeaderComponent extends Component {
38
38
  * @member {Object} _vdom
39
39
  */
40
40
  _vdom:
41
- {cn: [
42
- {tag: 'span', cls: ['neo-docs-header-text']}
43
- ]}
41
+ {cn: [
42
+ {tag: 'span', cls: ['neo-docs-header-text']}
43
+ ]}
44
44
  }
45
45
 
46
46
  /**
@@ -50,7 +50,7 @@ class MembersList extends Base {
50
50
  * @member {Object} _vdom={cn: []}
51
51
  */
52
52
  _vdom:
53
- {cn: []}
53
+ {cn: []}
54
54
  }
55
55
 
56
56
  /**
@@ -329,9 +329,9 @@ class MembersList extends Base {
329
329
 
330
330
  me.update();
331
331
 
332
- hasExamples && setTimeout(() => {
332
+ setTimeout(() => {
333
333
  Neo.main.addon.HighlightJS.syntaxHighlightInit();
334
- }, 100);
334
+ }, 100)
335
335
  }
336
336
 
337
337
  /**
@@ -412,7 +412,7 @@ class MembersList extends Base {
412
412
  cn : [{
413
413
  innerHTML: description.innerHTML
414
414
  },
415
- MembersList.createParametersTable(nestedParams)]
415
+ MembersList.createParametersTable(nestedParams)]
416
416
  }
417
417
  }
418
418
 
@@ -425,7 +425,7 @@ class MembersList extends Base {
425
425
  tag : 'td',
426
426
  innerHTML: param.type ? MembersList.escapeHtml(param.type.names.join(' | ')) : ''
427
427
  },
428
- description]
428
+ description]
429
429
  });
430
430
 
431
431
  if (hasDefaultValues) {
@@ -20,9 +20,9 @@ class ServiceWorker extends ServiceBase {
20
20
  */
21
21
  singleton: true,
22
22
  /**
23
- * @member {String} version='5.15.4'
23
+ * @member {String} version='5.16.0'
24
24
  */
25
- version: '5.15.4'
25
+ version: '5.16.0'
26
26
  }
27
27
 
28
28
  /**
@@ -1,9 +1,7 @@
1
- import CheckBox from '../../../../src/form/field/CheckBox.mjs';
2
1
  import ConfigurationViewport from '../../../ConfigurationViewport.mjs';
3
2
  import FileUploadField from '../../../../src/form/field/FileUpload.mjs';
4
3
  import NumberField from '../../../../src/form/field/Number.mjs';
5
- import Radio from '../../../../src/form/field/Radio.mjs';
6
- import TextField from '../../../../src/form/field/Text.mjs';
4
+ import Panel from '../../../../src/container/Panel.mjs';
7
5
 
8
6
  /**
9
7
  * @class Neo.examples.form.field.text.MainContainer
@@ -24,19 +22,79 @@ class MainContainer extends ConfigurationViewport {
24
22
  module : NumberField,
25
23
  labelText: 'width',
26
24
  listeners: {change: me.onConfigChange.bind(me, 'width')},
27
- maxValue : 300,
28
- minValue : 50,
25
+ maxValue : 350,
26
+ minValue : 200,
29
27
  stepSize : 5,
30
28
  style : {marginTop: '10px'},
31
29
  value : me.exampleComponent.width
32
- }]
30
+ }];
33
31
  }
34
32
 
35
33
  createExampleComponent() {
36
- return Neo.create(FileUploadField, {
37
- height: 50,
38
- width : 200
39
- })
34
+ return Neo.create(Panel, {
35
+ style : 'padding:1em',
36
+ items : [{
37
+ module : FileUploadField,
38
+ id : 'my-downloadable-test',
39
+ uploadUrl : 'http://127.0.0.1:3000/file-upload-test',
40
+ documentStatusUrl : 'http://127.0.0.1:3000/document-status-downloadable',
41
+ documentDeleteUrl : 'http://127.0.0.1:3000/document-delete',
42
+ downloadUrl : 'http://127.0.0.1:3000/getDocument',
43
+ width : 350,
44
+ maxSize : '10mb',
45
+ types : {
46
+ png : 1,
47
+ jpg : 1,
48
+ xls : 1,
49
+ pdf : 1
50
+ }
51
+ }, {
52
+ module : FileUploadField,
53
+ id : 'my-not-downloadable-test',
54
+ uploadUrl : 'http://127.0.0.1:3000/file-upload-test',
55
+ documentStatusUrl : 'http://127.0.0.1:3000/document-status-not-downloadable',
56
+ documentDeleteUrl : 'http://127.0.0.1:3000/document-delete',
57
+ downloadUrl : 'http://127.0.0.1:3000/getDocument',
58
+ width : 350,
59
+ maxSize : '10mb',
60
+ types : {
61
+ png : 1,
62
+ jpg : 1,
63
+ xls : 1,
64
+ pdf : 1
65
+ }
66
+ }, {
67
+ module : FileUploadField,
68
+ id : 'my-upload-fail-test',
69
+ uploadUrl : 'http://127.0.0.1:3000/file-upload-test-fail',
70
+ documentStatusUrl : 'http://127.0.0.1:3000/document-status',
71
+ documentDeleteUrl : 'http://127.0.0.1:3000/document-delete',
72
+ downloadUrl : 'http://127.0.0.1:3000/getDocument',
73
+ width : 350,
74
+ maxSize : '10mb',
75
+ types : {
76
+ png : 1,
77
+ jpg : 1,
78
+ xls : 1,
79
+ pdf : 1
80
+ }
81
+ }, {
82
+ module : FileUploadField,
83
+ id : 'my-scan-fail-test',
84
+ uploadUrl : 'http://127.0.0.1:3000/file-upload-test',
85
+ documentStatusUrl : 'http://127.0.0.1:3000/document-status-fail',
86
+ documentDeleteUrl : 'http://127.0.0.1:3000/document-delete',
87
+ downloadUrl : 'http://127.0.0.1:3000/getDocument',
88
+ width : 350,
89
+ maxSize : '10mb',
90
+ types : {
91
+ png : 1,
92
+ jpg : 1,
93
+ xls : 1,
94
+ pdf : 1
95
+ }
96
+ }]
97
+ });
40
98
  }
41
99
  }
42
100
 
@@ -0,0 +1,9 @@
1
+ # How to run this example
2
+ You need to install the following npm packages:
3
+ - cors
4
+ - express
5
+
6
+ To start the dummy backend, you need to use:
7
+ ```
8
+ node ./examples/form/field/fileupload/server.mjs
9
+ ```
@@ -0,0 +1,49 @@
1
+ import express from "express";
2
+ import cors from "cors"
3
+
4
+ const app = express();
5
+ const port = 3000;
6
+
7
+ app.use(cors());
8
+
9
+ app.post('/file-upload-test', async (req, res) => {
10
+
11
+ await new Promise(resolve => setTimeout(resolve, 3000));
12
+
13
+ res.set('Content-Type', 'application/json');
14
+ res.send('{"success":true,"documentId":"1"}');
15
+ })
16
+
17
+ app.post('/file-upload-test-fail', async (req, res) => {
18
+ res.set('Content-Type', 'application/json');
19
+ res.send('{"success":false,"message":"Something went wrong"}');
20
+ });
21
+
22
+ app.get('/document-status', async(req, res) => {
23
+ res.set('Content-Type', 'application/json');
24
+ res.send('{"status":"scanning"}');
25
+ });
26
+
27
+ app.get('/document-delete', async(req, res) => {
28
+ res.set('Content-Type', 'application/json');
29
+ res.send('');
30
+ });
31
+
32
+ app.get('/document-status-fail', async(req, res) => {
33
+ res.set('Content-Type', 'application/json');
34
+ res.send('{"status":"scan-failed"}');
35
+ });
36
+
37
+ app.get('/document-status-downloadable', async(req, res) => {
38
+ res.set('Content-Type', 'application/json');
39
+ res.send('{"status":"downloadable"}');
40
+ });
41
+
42
+ app.get('/document-status-not-downloadable', async(req, res) => {
43
+ res.set('Content-Type', 'application/json');
44
+ res.send('{"status":"not-downloadable"}');
45
+ });
46
+
47
+ app.listen(port, () => {
48
+ console.log(`Example app listening on port ${port}`)
49
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neo.mjs",
3
- "version": "5.15.4",
3
+ "version": "5.16.0",
4
4
  "description": "The webworkers driven UI framework",
5
5
  "type": "module",
6
6
  "repository": {
@@ -57,6 +57,7 @@
57
57
  "neo-jsdoc-x": "1.0.5",
58
58
  "postcss": "^8.4.27",
59
59
  "sass": "^1.65.1",
60
+ "showdown": "^2.1.0",
60
61
  "webpack": "^5.88.2",
61
62
  "webpack-cli": "^5.1.4",
62
63
  "webpack-dev-server": "4.15.1",
@@ -9,6 +9,8 @@
9
9
 
10
10
  .neo-docs-classdetails-headercomponent {
11
11
  background-color: v(docs-classdetails-headercomponent-background-color);
12
+ overflow-y : auto;
13
+ max-height : 250px;
12
14
  padding : 15px;
13
15
 
14
16
  .neo-docs-header-description {
@@ -23,4 +25,4 @@
23
25
  font-weight : 800;
24
26
  text-shadow : 2px 2px 3px rgba(200,200,200,0.1);
25
27
  }
26
- }
28
+ }
@@ -1,4 +1,250 @@
1
1
  .neo-file-upload-field {
2
- background-color: v(fileuploadfield-background-color);
3
- color : v(fileuploadfield-color);
2
+ background-color : v(fileuploadfield-background-color);
3
+ border-color : v(fileuploadfield-border-color);
4
+ color : v(fileuploadfield-color);
5
+ }
6
+
7
+ .neo-file-upload-field {
8
+ --upload-icon-codepoint : "\f062";
9
+ --cancel-icon-codepoint : "\f00d";
10
+ --delete-icon-codepoint : "\f014";
11
+ --success-icon-codepoint : "\f00c";
12
+ --download-icon-codepoint : "\f019";
13
+
14
+ min-height : 3.5rem;
15
+ position : relative;
16
+ display : flex;
17
+ align-items : center;
18
+ padding : 0.5rem;
19
+ gap : 0.5rem;
20
+ border : 1px solid var(--fileuploadfield-border-color);
21
+ border-radius : 2px;
22
+
23
+ // Space for any error message
24
+ margin-bottom : 2.5rem;
25
+
26
+ &:focus-within {
27
+ &::after {
28
+ content : "";
29
+ pointer-events : none;
30
+ position : absolute;
31
+ top : -2px;
32
+ right : -2px;
33
+ bottom : -2px;
34
+ left : -2px;
35
+ border : 1px solid var(--fileuploadfield-focus-color);
36
+ border-radius : 3px;
37
+ }
38
+ }
39
+
40
+ // Icons
41
+ i, button {
42
+ font-style : normal;
43
+ font-size : 18px;
44
+ flex : 0 0 2rem;
45
+ height : 2rem;
46
+ border-radius : 50%;
47
+ font-family : var(--fa-style-family,"Font Awesome 6 Free");
48
+ display : flex;
49
+ align-items : center;
50
+ justify-content : center;
51
+ border : 0 none;
52
+ background : transparent;
53
+ color : inherit;
54
+
55
+ &::after {
56
+ font-weight : var(--fa-style, 900);
57
+ display : flex;
58
+ align-items : center;
59
+ justify-content : center;
60
+ aspect-ratio : 1;
61
+ border-radius : 50%;
62
+ }
63
+ }
64
+
65
+ // Cursor appearance is invitation to click
66
+ .neo-file-upload-action-button {
67
+ cursor : pointer;
68
+ &:hover {
69
+ background-color: var(--fileuploadfield-hover-color);
70
+ }
71
+ &:active {
72
+ background-color: var(--fileuploadfield-pressed-color);
73
+ }
74
+ }
75
+
76
+ .neo-file-upload-filename {
77
+ font-weight : bold;
78
+ }
79
+
80
+ .neo-file-upload-filename, .neo-file-upload-state {
81
+ white-space : nowrap;
82
+ overflow : hidden;
83
+ text-overflow : ellipsis;
84
+ color : inherit; // For when it becomes a link
85
+ }
86
+
87
+ // The file input is only visible in the ready state
88
+ &:not(.neo-file-upload-state-ready) {
89
+ input[type="file"] {
90
+ display : none;
91
+ }
92
+ }
93
+
94
+ &.neo-invalid {
95
+ .neo-file-upload-error-message {
96
+ display : initial;
97
+ }
98
+ }
99
+ }
100
+
101
+ // During upload, it's a progress circle.
102
+ // Of the upload fails, the process circle stops but remains visible
103
+ .neo-file-upload-state-uploading, .neo-file-upload-state-upload-failed {
104
+ .neo-file-upload-state-icon {
105
+ background-image : conic-gradient(
106
+ var(--fileuploadfield-progress-color) 0 var(--upload-progress),
107
+ transparent var(--upload-progress) 1turn
108
+ );
109
+
110
+ &::after {
111
+ background-color : var(--fileuploadfield-background-color);
112
+ content : var(--upload-icon-codepoint);
113
+ flex : 0 0 calc(100% - 6px);
114
+ }
115
+ }
116
+ }
117
+
118
+ .neo-file-upload-state-processing {
119
+ .neo-file-upload-state-icon {
120
+ background-image : conic-gradient(
121
+ from 0deg, transparent 0deg,
122
+ var(--fileuploadfield-progress-color) 180deg,
123
+ transparent 180deg
124
+ );
125
+ animation: spinner-rotation 3s linear infinite;
126
+
127
+ &::after {
128
+ content : "";
129
+ flex : 0 0 calc(100% - 6px);
130
+ }
131
+ }
132
+ }
133
+
134
+ // While uploading and scanning, we can abort the whole upload/scan process
135
+ .neo-file-upload-state-uploading, .neo-file-upload-state-processing {
136
+ .neo-file-upload-action-button {
137
+ &::after {
138
+ content : var(--cancel-icon-codepoint);
139
+ }
140
+ }
141
+ }
142
+
143
+ // If the upload or scan failed, we show an error UI and the action button cancels
144
+ .neo-file-upload-state-upload-failed, .neo-file-upload-state-scan-failed {
145
+ --fileuploadfield-progress-color : var(--fileuploadfield-error-color);
146
+ border-color : var(--fileuploadfield-error-color);
147
+
148
+ .neo-file-upload-state-icon, .neo-file-upload-action-button {
149
+ &::after {
150
+ content : var(--cancel-icon-codepoint );
151
+ }
152
+ }
153
+
154
+ .neo-file-upload-state-icon, .neo-file-upload-state {
155
+ color : var(--fileuploadfield-error-color);
156
+ }
157
+ }
158
+
159
+ .neo-file-upload-state-scan-failed {
160
+ .neo-file-upload-state-icon {
161
+ border : 3px solid var(--fileuploadfield-error-color);
162
+ }
163
+ }
164
+
165
+ .neo-file-upload-state-not-downloadable {
166
+ .neo-file-upload-state-icon {
167
+ color : var(--fileuploadfield-success-color);
168
+ border : 3px solid var(--fileuploadfield-success-color);
169
+
170
+ &::after {
171
+ content : var(--success-icon-codepoint);
172
+ }
173
+ }
174
+ .neo-file-upload-action-button {
175
+ &::after {
176
+ content : var(--delete-icon-codepoint);
177
+ }
178
+ }
179
+ }
180
+
181
+ .neo-file-upload-state-downloadable {
182
+ &:has(.neo-file-upload-filename:hover) {
183
+ background-color: var(--fileuploadfield-hover-color);
184
+ }
185
+ &:has(.neo-file-upload-filename:active) {
186
+ background-color: var(--fileuploadfield-pressed-color);
187
+ }
188
+ .neo-file-upload-state-icon {
189
+ background-color : var(--fileuploadfield-downloadable-state-color);
190
+ border : 0 none;
191
+
192
+ &::after {
193
+ content : var(--download-icon-codepoint);
194
+ background-color: transparent;
195
+ }
196
+ }
197
+ .neo-file-upload-action-button {
198
+ &::after {
199
+ content : var(--delete-icon-codepoint);
200
+ }
201
+ }
202
+ }
203
+
204
+ .neo-file-upload-state-ready {
205
+ // Only the input field is visible when in ready state
206
+ // It takes up the whole component, and is the only interactive item
207
+ :not(input[type="file"]) {
208
+ display : none;
209
+ }
210
+ input::file-selector-button {
211
+ position : absolute;
212
+ border : 0 none;
213
+ margin : 0;
214
+ top : 0;
215
+ left : 0;
216
+ right : 0;
217
+ bottom : 0;
218
+ background-color : var(--fileuploadfield-background-color);
219
+ color : var(--fileuploadfield-color);
220
+ cursor : pointer;
221
+ }
222
+ }
223
+
224
+ .neo-file-upload-body {
225
+ flex : 1 1 0%;
226
+ display : flex;
227
+ flex-flow : column nowrap;
228
+ line-height : 1;
229
+ gap : 0.2rem;
230
+ overflow : hidden;
231
+ align-items : flex-start;}
232
+
233
+ .neo-file-upload-error-message {
234
+ display : none;
235
+ position : absolute;
236
+ inset-inline-start : 0;
237
+ white-space : nowrap;
238
+ top : 100%;
239
+ padding : 0.5rem;
240
+ color : var(--fileuploadfield-error-color);
241
+ }
242
+
243
+ @keyframes spinner-rotation {
244
+ 0% {
245
+ transform: rotate(0deg);
246
+ }
247
+ 100% {
248
+ transform: rotate(360deg);
249
+ }
4
250
  }
@@ -1,11 +1,27 @@
1
1
  $neoMap: map-merge($neoMap, (
2
- 'fileuploadfield-background-color': red,
3
- 'fileuploadfield-color' : #fff
2
+ 'fileuploadfield-background-color' : rgb(62, 51, 51),
3
+ 'fileuploadfield-color' : #fff,
4
+ 'fileuploadfield-border-color' : #d5d5d5,
5
+ 'fileuploadfield-progress-color' : #fff,
6
+ 'fileuploadfield-error-color' : #f00,
7
+ 'fileuploadfield-success-color' : rgb(71, 226, 71),
8
+ 'fileuploadfield-hover-color' : #0f0f0f,
9
+ 'fileuploadfield-pressed-color' : #0e0e0e,
10
+ 'fileuploadfield-downloadable-state-color' : #181818,
11
+ 'fileuploadfield-focus-color' : #80d3f4
4
12
  ));
5
13
 
6
14
  @if $useCssVars == true {
7
15
  :root .neo-theme-dark { // .neo-fileuploadfield
8
- --fileuploadfield-background-color: #{neo(fileuploadfield-background-color)};
9
- --fileuploadfield-color : #{neo(fileuploadfield-color)};
16
+ --fileuploadfield-background-color : #{neo(fileuploadfield-background-color)};
17
+ --fileuploadfield-color : #{neo(fileuploadfield-color)};
18
+ --fileuploadfield-border-color : #{neo(fileuploadfield-border-color)};
19
+ --fileuploadfield-progress-color : #{neo(fileuploadfield-progress-color)};
20
+ --fileuploadfield-error-color : #{neo(fileuploadfield-error-color)};
21
+ --fileuploadfield-success-color : #{neo(fileuploadfield-success-color)};
22
+ --fileuploadfield-hover-color : #{neo(fileuploadfield-hover-color)};
23
+ --fileuploadfield-pressed-color : #{neo(fileuploadfield-pressed-color)};
24
+ --fileuploadfield-downloadable-state-color : #{neo(fileuploadfield-pressed-color)};
25
+ --fileuploadfield-focus-color : #{neo(fileuploadfield-focus-color)};
10
26
  }
11
27
  }
@@ -1,11 +1,27 @@
1
1
  $neoMap: map-merge($neoMap, (
2
- 'fileuploadfield-background-color': darkblue,
3
- 'fileuploadfield-color' : #fff
2
+ 'fileuploadfield-background-color' : #fff,
3
+ 'fileuploadfield-color' : #000,
4
+ 'fileuploadfield-border-color' : #d5d5d5,
5
+ 'fileuploadfield-progress-color' : #5d94f3,
6
+ 'fileuploadfield-error-color' : #f00,
7
+ 'fileuploadfield-success-color' : rgb(112, 169, 112),
8
+ 'fileuploadfield-hover-color' : #f0f0f0,
9
+ 'fileuploadfield-pressed-color' : #e0e0e0,
10
+ 'fileuploadfield-downloadable-state-color' : #d9d9d9,
11
+ 'fileuploadfield-focus-color' : #80d3f4
4
12
  ));
5
13
 
6
14
  @if $useCssVars == true {
7
15
  :root .neo-theme-light { // .neo-fileuploadfield
8
- --fileuploadfield-background-color: #{neo(fileuploadfield-background-color)};
9
- --fileuploadfield-color : #{neo(fileuploadfield-color)};
16
+ --fileuploadfield-background-color : #{neo(fileuploadfield-background-color)};
17
+ --fileuploadfield-color : #{neo(fileuploadfield-color)};
18
+ --fileuploadfield-border-color : #{neo(fileuploadfield-border-color)};
19
+ --fileuploadfield-progress-color : #{neo(fileuploadfield-progress-color)};
20
+ --fileuploadfield-error-color : #{neo(fileuploadfield-error-color)};
21
+ --fileuploadfield-success-color : #{neo(fileuploadfield-success-color)};
22
+ --fileuploadfield-hover-color : #{neo(fileuploadfield-hover-color)};
23
+ --fileuploadfield-pressed-color : #{neo(fileuploadfield-pressed-color)};
24
+ --fileuploadfield-downloadable-state-color : #{neo(fileuploadfield-pressed-color)};
25
+ --fileuploadfield-focus-color : #{neo(fileuploadfield-focus-color)};
10
26
  }
11
27
  }
@@ -245,12 +245,12 @@ const DefaultConfig = {
245
245
  useVdomWorker: true,
246
246
  /**
247
247
  * buildScripts/injectPackageVersion.mjs will update this value
248
- * @default '5.15.4'
248
+ * @default '5.16.0'
249
249
  * @memberOf! module:Neo
250
250
  * @name config.version
251
251
  * @type String
252
252
  */
253
- version: '5.15.4'
253
+ version: '5.16.0'
254
254
  };
255
255
 
256
256
  Object.assign(DefaultConfig, {
@@ -1,6 +1,81 @@
1
1
  import Base from '../../form/field/Base.mjs';
2
+ import NeoArray from '../../util/Array.mjs';
3
+
4
+ const
5
+ sizeRE = /^(\d+)(kb|mb|gb)?$/i,
6
+ sizeMultiplier = {
7
+ unit : 1,
8
+ kb : 1000,
9
+ mb : 1000000,
10
+ gb : 1000000000
11
+ };
2
12
 
3
13
  /**
14
+ * An accessible file uploading widget which automatically commences an upload as soon as
15
+ * a file is selected using the UI.
16
+ *
17
+ * The URL to which the file must be uploaded is specified in the {@link #member-uploadUrl} property.
18
+ * This service must return a JSON status response in the following form for successful uploads:
19
+ *
20
+ * ```json
21
+ * {
22
+ * "success" : true,
23
+ * "documentId" : 1
24
+ * }
25
+ * ```
26
+ *
27
+ * And the following form for unsuccessful uploads:
28
+ *
29
+ * ```json
30
+ * {
31
+ * "success" : false,
32
+ * "message" : "Why the upload was rejected"
33
+ * }
34
+ * ```
35
+ *
36
+ * The name of the `documentId` property is configured in {@link #member-documentIdParameter}.
37
+ * It defaults to `'documentId'`.
38
+ *
39
+ * The `documentId` is used when requesting the document malware scan status, and when requesting
40
+ * that the document be deleted, or downloaded.
41
+ *
42
+ * If the upload is successful, then the {@link #member-documentStatusUrl} is polled until the
43
+ * malware scan. The document id returned from the upload is passed in the parameter named
44
+ * by the {@link #member-documentIdParameter}. It defaults to `'documentId'`.
45
+ *
46
+ * This service must return a JSON status response in the following if the scan is still progressing:
47
+ *
48
+ * ```json
49
+ * {
50
+ * "status" : "scanning"
51
+ * }
52
+ * ```
53
+ *
54
+ * And the following form is malware was detected:
55
+ *
56
+ * ```json
57
+ * {
58
+ * "status" : "scan-failed"
59
+ * }
60
+ * ```
61
+ *
62
+ * After a successful scan, a document may or may not be downloadable.
63
+ *
64
+ * For a downloadable document, the response must be:
65
+ *
66
+ * ```json
67
+ * {
68
+ * "status" : "downloadable"
69
+ * }
70
+ * ```
71
+ *
72
+ * For a non-downloadable document, the response must be:
73
+ *
74
+ * ```json
75
+ * {
76
+ * "status" : "not-downloadable"
77
+ * }
78
+ * ```
4
79
  * @class Neo.form.field.FileUpload
5
80
  * @extends Neo.form.field.Base
6
81
  */
@@ -17,15 +92,448 @@ class FileUpload extends Base {
17
92
  */
18
93
  ntype: 'file-upload-field',
19
94
  /**
20
- * @member {String[]}} baseCls=['neo-file-upload-field']
95
+ * @member {String[]} baseCls=['neo-file-upload-field']
21
96
  * @protected
22
97
  */
23
98
  baseCls: ['neo-file-upload-field'],
24
99
  /**
25
- * @member {Object}} _vdom
100
+ * @member {Object} _vdom
26
101
  */
27
- _vdom:
28
- {tag: 'input', type: 'file'}
102
+ _vdom: {
103
+ cn : [
104
+ {
105
+ tag : 'i',
106
+ cls : 'neo-file-upload-state-icon'
107
+ },
108
+ {
109
+ cls : 'neo-file-upload-body',
110
+ cn : [{
111
+ cls : 'neo-file-upload-filename'
112
+ }, {
113
+ cls : 'neo-file-upload-state'
114
+ }]
115
+ },
116
+ {
117
+ cls : 'neo-file-upload-action-button',
118
+ tag : 'button'
119
+ },
120
+ {
121
+ cls : 'neo-file-upload-input',
122
+ tag : 'input',
123
+ type : 'file'
124
+ },
125
+ {
126
+ cls : 'neo-file-upload-error-message'
127
+ }
128
+ ]
129
+ },
130
+
131
+ cls : [],
132
+
133
+ /**
134
+ * The URL of the file upload service to which the selected file is sent.
135
+ *
136
+ * This service must return a JSON response of the form:
137
+ *
138
+ * ```json
139
+ * {
140
+ * "success" : true,
141
+ * "message" : "Only needed if the success property is false",
142
+ * "documentId" : 1
143
+ * }
144
+ * ```
145
+ *
146
+ * The document id is needed so that this widget can follow up and request the results of the
147
+ * scan operation to see if the file was accepted, and whether it is to be subsequently downloadable.
148
+ *
149
+ * The document status request URL must be configured in {@link #member-documentStatusUrl}
150
+ * @member {String} uploadUrl
151
+ */
152
+ uploadUrl_ : null,
153
+
154
+ /**
155
+ * The name of the JSON property in which the document id is returned in the upload response
156
+ * JSON packet and the HTTP parameter which is used when requesting a malware scan and a document
157
+ * deletion.
158
+ *
159
+ * @member {String} downloadUrl
160
+ */
161
+ documentIdParameter : 'documentId',
162
+
163
+ /**
164
+ * The URL from which the file may be downloaded after it has finished its scan.
165
+ *
166
+ * The document id returned from the {@link #member-uploadUrl upload} is passed in the parameter named
167
+ * by the {@link #member-documentIdParameter}. It defaults to `'documentId'`.
168
+ *
169
+ * @member {String} downloadUrl
170
+ */
171
+ downloadUrl_ : null,
172
+
173
+ /**
174
+ * The URL of the file status reporting service.
175
+ *
176
+ * This widget will use this service after a successful upload to determine its next
177
+ * state.
178
+ *
179
+ * This service must return a JSON response of the form:
180
+ *
181
+ * ```json
182
+ * {
183
+ * "status" : "scanning" or "scan-failed" or "downloadable or "not-downloadable"
184
+ * }
185
+ * ```
186
+ *
187
+ * @member {String} documentStatusUrl
188
+ */
189
+ documentStatusUrl : null,
190
+
191
+ /**
192
+ * The URL of the file deletion service.
193
+ *
194
+ * This widget will use this service after a successful upload to determine its next
195
+ * state.
196
+ *
197
+ * If this service yields an HTTP 200 status, the deletion is taken to have been successful.
198
+ *
199
+ * @member {String} documentDeleteUrl
200
+ */
201
+ documentDeleteUrl : null,
202
+
203
+ headers_ : {},
204
+
205
+ /**
206
+ * @member {String} state_=null
207
+ */
208
+ state_: 'ready',
209
+
210
+ /**
211
+ * @member {Object} types=null
212
+ */
213
+ types_ : null,
214
+
215
+ /**
216
+ * @member {String|Number} maxSize
217
+ */
218
+ maxSize_: null
219
+ }
220
+
221
+ /**
222
+ * @param {Object} config
223
+ */
224
+ construct(config) {
225
+ super.construct(config);
226
+
227
+ const me = this;
228
+
229
+ me.addDomListeners([
230
+ { input : me.onInputValueChange, scope: me},
231
+ { click : me.onActionButtonClick, delegate : '.neo-file-upload-action-button', scope : me}
232
+ ]);
233
+ }
234
+
235
+ async clear() {
236
+ const me = this;
237
+
238
+ me.vdom.cn[3] = {
239
+ cls : 'neo-file-upload-input',
240
+ tag : 'input',
241
+ type : 'file',
242
+ value : ''
243
+ };
244
+ me.state = 'ready';
245
+ me.error = '';
246
+
247
+ // We have to wait for the DOM to have changed, and the input field to be visible
248
+ await new Promise(resolve => setTimeout(resolve, 100));
249
+ me.focus(me.vdom.cn[3].id);
250
+ }
251
+
252
+ /**
253
+ * @param {Object} data
254
+ * @protected
255
+ */
256
+ onInputValueChange({ files }) {
257
+ const
258
+ me = this,
259
+ { types } = me;
260
+
261
+ if (files.length) {
262
+ const
263
+ file = files.item(0),
264
+ pointPos = file.name.lastIndexOf('.'),
265
+ type = pointPos > -1 ? file.name.slice(pointPos + 1) : '';
266
+
267
+ if (me.types && !types[type]) {
268
+ me.error = `Please use these file types: .${Object.keys(types).join(' .')}`;
269
+ }
270
+ else if (file.size > me.maxSize) {
271
+ me.error = `File size exceeds ${String(me._maxSize).toUpperCase()}`;
272
+ }
273
+ // If it passes the type and maxSize check, upload it
274
+ else {
275
+ me.fileSize = me.formatSize(file.size);
276
+ me.error = '';
277
+ me.upload(file);
278
+ }
279
+ }
280
+ // If cleared, we go back to ready state
281
+ else {
282
+ me.state = 'ready';
283
+ }
284
+ }
285
+
286
+ async upload(file) {
287
+ const
288
+ me = this,
289
+ xhr = me.xhr = new XMLHttpRequest(),
290
+ { upload } = xhr,
291
+ fileData = new FormData();
292
+
293
+ // Show the action button
294
+ me.state = 'starting';
295
+
296
+ // We have to wait for the DOM to have changed, and the action button to be visible
297
+ await new Promise(resolve => setTimeout(resolve, 100));
298
+ me.focus(me.vdom.cn[2].id);
299
+
300
+ me.vdom.cn[1].cn[0].innerHTML = file.name;
301
+ me.update();
302
+ me.state = 'uploading';
303
+
304
+ fileData.append("file", file);
305
+
306
+ // React to upload state changes
307
+ upload.addEventListener('progress', me.onUploadProgress.bind(me));
308
+ upload.addEventListener('error', me.onUploadError.bind(me));
309
+ upload.addEventListener('abort', me.onUploadAbort.bind(me));
310
+ xhr.addEventListener('loadend', me.onUploadDone.bind(me));
311
+
312
+ xhr.open("POST", me.uploadUrl, true);
313
+
314
+ xhr.send(fileData);
315
+ }
316
+
317
+ onUploadProgress({ loaded, total }) {
318
+ const
319
+ progress = this.progress = loaded / total,
320
+ { vdom } = this;
321
+
322
+ (vdom.style || (vdom.style = {}))['--upload-progress'] = `${progress}turn`;
323
+
324
+ vdom.cn[1].cn[1].innerHTML = `Uploading... (${Math.round(progress * 100)}%)`;
325
+
326
+ this.uploadSize = loaded;
327
+ this.update();
328
+ }
329
+
330
+ onUploadAbort(e) {
331
+ this.xhr = null;
332
+ this.clear();
333
+ }
334
+
335
+ onUploadError(e) {
336
+ this.xhr = null;
337
+ this.state = 'upload-failed';
338
+ this.error = e.type;
339
+ }
340
+
341
+ onUploadDone({ loaded, target : xhr }) {
342
+ const me = this;
343
+
344
+ me.xhr = null;
345
+
346
+ if (loaded !== 0) {
347
+ const response = JSON.parse(xhr.response);
348
+
349
+ if (response.success) {
350
+ me.documentId = response[me.documentIdParameter];
351
+
352
+ // The status check phase is optional.
353
+ // If no URL specified, the file is taken to be downloadable.
354
+ if (me.documentStatusUrl) {
355
+ me.state = 'processing';
356
+
357
+ // Start polling the server to see when the scan has a result;
358
+ me.checkDocumentStatus();
359
+ }
360
+ else {
361
+ me.state = 'downloadable';
362
+ }
363
+ }
364
+ else {
365
+ me.error = response.message;
366
+ me.state = 'upload-failed';
367
+ }
368
+ }
369
+ }
370
+
371
+ onActionButtonClick() {
372
+ const
373
+ me = this,
374
+ { state } = me;
375
+
376
+ // When they click the action button, depending on which state we are in, we go to
377
+ // different states.
378
+ switch (state) {
379
+ // During upload, its an abort
380
+ case 'uploading':
381
+ me.abortUpload();
382
+ break;
383
+
384
+ // If the upload or the scan failed, the document will not have been
385
+ // saved, so we just go back to ready state
386
+ case 'upload-failed':
387
+ case 'scan-failed':
388
+ me.clear();
389
+ me.state = 'ready';
390
+ break;
391
+
392
+ // During scanning and for stored documents, we need to tell the server the document
393
+ // is not required.
394
+ case 'processing':
395
+ case 'downloadable':
396
+ case 'not-downloadable':
397
+ me.deleteDocument();
398
+ break;
399
+ }
400
+ }
401
+
402
+ abortUpload() {
403
+ this.xhr?.abort();
404
+ }
405
+
406
+ async deleteDocument() {
407
+ // We ask the server to delete using our this.documentId
408
+ const statusResponse = await fetch(`${this.documentDeleteUrl}?${this.documentIdParameter}=${this.documentId}`);
409
+
410
+ // Success
411
+ if (String(statusResponse.status).slice(0, 1) === '2') {
412
+ this.clear();
413
+ this.state = 'ready';
414
+ }
415
+ else {
416
+ this.error = `Document delete service error: ${statusResponse.statusText}`;
417
+ }
418
+ }
419
+
420
+ async checkDocumentStatus() {
421
+ const me = this;
422
+
423
+ if (this.state === 'processing') {
424
+ const statusResponse = await fetch(`${this.documentStatusUrl}?${me.documentIdParameter}=${this.documentId}`);
425
+
426
+ // Success
427
+ if (String(statusResponse.status).slice(0, 1) === '2') {
428
+ const status = (await statusResponse.json()).status;
429
+
430
+ switch (status) {
431
+ case 'scanning':
432
+ setTimeout(() => me.checkDocumentStatus(), 2000);
433
+ break;
434
+ default:
435
+ me.state = status;
436
+ }
437
+ }
438
+ else {
439
+ this.error = `Document status service error: ${statusResponse.statusText}`;
440
+ }
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Triggered after the state config got changed
446
+ * @param {String} value
447
+ * @param {String} oldValue
448
+ * @protected
449
+ */
450
+ afterSetState(value, oldValue) {
451
+ const
452
+ me = this,
453
+ {
454
+ vdom
455
+ } = me,
456
+ anchor = vdom.cn[1].cn[0],
457
+ status = vdom.cn[1].cn[1];
458
+
459
+ switch (value) {
460
+ case 'ready':
461
+ anchor.tag = 'div';
462
+ anchor.href = '';
463
+ break;
464
+ case 'upload-failed':
465
+ status.innerHTML = `Upload failed... (${Math.round(me.progress * 100)}%)`;
466
+ break;
467
+ case 'processing':
468
+ status.innerHTML = `Scanning... (${me.formatSize(me.uploadSize)})`;
469
+ break;
470
+ case 'scan-failed':
471
+ status.innerHTML = `Malware found in file. \u2022 ${me.fileSize}`;
472
+ me.error = 'Please check the file and try again';
473
+ break;
474
+ case 'downloadable':
475
+ anchor.tag = 'a';
476
+ anchor.href = `${me.downloadUrl}?${me.documentIdParameter}=${me.documentId}`;
477
+ status.innerHTML = me.fileSize;
478
+ break;
479
+ case 'not-downloadable':
480
+ status.innerHTML = `Successfully uploaded \u2022 ${me.fileSize}`;
481
+ }
482
+
483
+ me.update();
484
+
485
+ // Processing above may mutate cls
486
+ const { cls } = me;
487
+
488
+ NeoArray.remove(cls, 'neo-file-upload-state-' + oldValue);
489
+ NeoArray.add(cls, 'neo-file-upload-state-' + value);
490
+ me.cls = cls;
491
+ }
492
+
493
+ beforeGetMaxSize(maxSize) {
494
+ // Not configured means no limit
495
+ if (maxSize == null) {
496
+ return Number.MAX_SAFE_INTEGER;
497
+ }
498
+
499
+ // Split eg "100mb" into the numeric and units parts
500
+ const sizeParts = sizeRE.exec(maxSize);
501
+
502
+ if (sizeParts) {
503
+ // Convert mb to 1000000 etc
504
+ const multiplier = sizeMultiplier[(sizeParts[2]||'unit').toLowerCase()];
505
+
506
+ return parseInt(sizeParts[1]) * multiplier;
507
+ }
508
+ }
509
+
510
+ set error(text) {
511
+ const { cls } = this;
512
+
513
+ if (text) {
514
+ this.vdom.cn[4].cn = [{
515
+ vtype : 'text',
516
+ html : text
517
+ }];
518
+ NeoArray.add(cls, 'neo-invalid');
519
+ }
520
+ else {
521
+ NeoArray.remove(cls, 'neo-invalid');
522
+ }
523
+
524
+ this.cls = cls;
525
+ this.update();
526
+ }
527
+
528
+ formatSize(bytes, separator = '', postFix = '') {
529
+ if (bytes) {
530
+ const
531
+ sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'],
532
+ i = Math.min(parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString(), 10), sizes.length - 1);
533
+
534
+ return `${(bytes / (1024 ** i)).toFixed(i ? 1 : 0)}${separator}${sizes[i]}${postFix}`;
535
+ }
536
+ return 'n/a';
29
537
  }
30
538
  }
31
539
 
@@ -44,7 +44,7 @@ class Markdown extends Base {
44
44
  */
45
45
  construct(config) {
46
46
  super.construct(config);
47
- DomAccess.addScript({src: this.showdownPath});
47
+ DomAccess.addScript({src: this.showdownPath})
48
48
  }
49
49
 
50
50
  /**
@@ -55,7 +55,7 @@ class Markdown extends Base {
55
55
  markdownToHtml(markdown) {
56
56
  let converter = new showdown.Converter();
57
57
 
58
- return converter.makeHtml(markdown);
58
+ return converter.makeHtml(markdown)
59
59
  }
60
60
  }
61
61
 
@@ -0,0 +1,79 @@
1
+ import Base from '../../core/Base.mjs';
2
+ import DomAccess from '../DomAccess.mjs'
3
+
4
+ /**
5
+ * @class Neo.main.addon.ResizeObserver
6
+ * @extends Neo.core.Base
7
+ * @singleton
8
+ */
9
+ class ResizeObserver extends Base {
10
+ static config = {
11
+ /**
12
+ * @member {String} className='Neo.main.addon.ResizeObserver'
13
+ * @protected
14
+ */
15
+ className: 'Neo.main.addon.ResizeObserver',
16
+ /**
17
+ * @member {#ResizeObserver|null} instance=null
18
+ * @protected
19
+ */
20
+ instance: null,
21
+ /**
22
+ * Remote method access for other workers
23
+ * @member {Object} remote
24
+ * @protected
25
+ */
26
+ remote: {
27
+ app: [
28
+ 'register',
29
+ 'unregister'
30
+ ]
31
+ },
32
+ /**
33
+ * @member {Boolean} singleton=true
34
+ * @protected
35
+ */
36
+ singleton: true
37
+ }
38
+
39
+ /**
40
+ * @param {Object} config
41
+ */
42
+ construct(config) {
43
+ let me = this;
44
+
45
+ me.resizeObserver = new ResizeObserver(me.onResize.bind(me))
46
+ }
47
+
48
+ /**
49
+ * Internal callback for the ResizeObserver instance
50
+ * @param {HTMLElement[]} entries
51
+ * @param {ResizeObserver} observer
52
+ * @protected
53
+ */
54
+ onResize(entries, observer) {
55
+ console.log('onResize', entries)
56
+ }
57
+
58
+ /**
59
+ * @param {Object} data
60
+ * @param {String} data.id
61
+ */
62
+ register(data) {
63
+ this.instance.observe(DomAccess.getElement(data.id))
64
+ }
65
+
66
+ /**
67
+ * @param {Object} data
68
+ * @param {String} data.id
69
+ */
70
+ unregister(data) {
71
+ this.instance.unobserve(DomAccess.getElement(data.id))
72
+ }
73
+ }
74
+
75
+ Neo.applyClassConfig(ResizeObserver);
76
+
77
+ let instance = Neo.applyClassConfig(ResizeObserver);
78
+
79
+ export default instance;
@@ -30,6 +30,7 @@ const globalDomEvents = [
30
30
  'mouseenter',
31
31
  'mouseleave',
32
32
  'mouseup',
33
+ 'scroll',
33
34
  'wheel'
34
35
  ];
35
36