diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-08-05 00:50:07 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-08-11 09:03:39 +0200 |
commit | 02c01341f4dae30ec6b81fcb644952393d73c4a8 (patch) | |
tree | aca3f2b118bb123457fd38724be68fe877504c75 /client/src/app/shared | |
parent | 766d13b4470de02d3d7bec94188260b89a356399 (diff) | |
download | PeerTube-02c01341f4dae30ec6b81fcb644952393d73c4a8.tar.gz PeerTube-02c01341f4dae30ec6b81fcb644952393d73c4a8.tar.zst PeerTube-02c01341f4dae30ec6b81fcb644952393d73c4a8.zip |
add ng-select for templatable select options
- create select-tags component to replace ngx-chips
- create select-options to factorize option selection in forms
- create select-channel to simplify channel selection
- refactor tags validation
Diffstat (limited to 'client/src/app/shared')
12 files changed, 254 insertions, 11 deletions
diff --git a/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts index 9b24e4f62..c96e4ef66 100644 --- a/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts +++ b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | 1 | import { I18n } from '@ngx-translate/i18n-polyfill' |
2 | import { Validators } from '@angular/forms' | 2 | import { Validators, ValidatorFn, ValidationErrors, AbstractControl } from '@angular/forms' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { BuildFormValidator } from './form-validator.service' | 4 | import { BuildFormValidator } from './form-validator.service' |
5 | 5 | ||
@@ -13,7 +13,8 @@ export class VideoValidatorsService { | |||
13 | readonly VIDEO_IMAGE: BuildFormValidator | 13 | readonly VIDEO_IMAGE: BuildFormValidator |
14 | readonly VIDEO_CHANNEL: BuildFormValidator | 14 | readonly VIDEO_CHANNEL: BuildFormValidator |
15 | readonly VIDEO_DESCRIPTION: BuildFormValidator | 15 | readonly VIDEO_DESCRIPTION: BuildFormValidator |
16 | readonly VIDEO_TAGS: BuildFormValidator | 16 | readonly VIDEO_TAGS_ARRAY: BuildFormValidator |
17 | readonly VIDEO_TAG: BuildFormValidator | ||
17 | readonly VIDEO_SUPPORT: BuildFormValidator | 18 | readonly VIDEO_SUPPORT: BuildFormValidator |
18 | readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator | 19 | readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator |
19 | readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator | 20 | readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator |
@@ -71,7 +72,7 @@ export class VideoValidatorsService { | |||
71 | } | 72 | } |
72 | } | 73 | } |
73 | 74 | ||
74 | this.VIDEO_TAGS = { | 75 | this.VIDEO_TAG = { |
75 | VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], | 76 | VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], |
76 | MESSAGES: { | 77 | MESSAGES: { |
77 | 'minlength': this.i18n('A tag should be more than 2 characters long.'), | 78 | 'minlength': this.i18n('A tag should be more than 2 characters long.'), |
@@ -79,6 +80,14 @@ export class VideoValidatorsService { | |||
79 | } | 80 | } |
80 | } | 81 | } |
81 | 82 | ||
83 | this.VIDEO_TAGS_ARRAY = { | ||
84 | VALIDATORS: [ Validators.maxLength(5), this.arrayTagLengthValidator() ], | ||
85 | MESSAGES: { | ||
86 | 'maxlength': this.i18n('A maximum of 5 tags can be used on a video.'), | ||
87 | 'arrayTagLength': this.i18n('A tag should be more than 2, and less than 30 characters long.') | ||
88 | } | ||
89 | } | ||
90 | |||
82 | this.VIDEO_SUPPORT = { | 91 | this.VIDEO_SUPPORT = { |
83 | VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], | 92 | VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], |
84 | MESSAGES: { | 93 | MESSAGES: { |
@@ -99,4 +108,16 @@ export class VideoValidatorsService { | |||
99 | MESSAGES: {} | 108 | MESSAGES: {} |
100 | } | 109 | } |
101 | } | 110 | } |
111 | |||
112 | arrayTagLengthValidator (min = 2, max = 30): ValidatorFn { | ||
113 | return (control: AbstractControl): ValidationErrors => { | ||
114 | const array = control.value as Array<string> | ||
115 | |||
116 | if (array.every(e => e.length > min && e.length < max)) { | ||
117 | return null | ||
118 | } | ||
119 | |||
120 | return { 'arrayTagLength': true } | ||
121 | } | ||
122 | } | ||
102 | } | 123 | } |
diff --git a/client/src/app/shared/shared-forms/select-channel.component.html b/client/src/app/shared/shared-forms/select-channel.component.html new file mode 100644 index 000000000..897d13ee7 --- /dev/null +++ b/client/src/app/shared/shared-forms/select-channel.component.html | |||
@@ -0,0 +1,16 @@ | |||
1 | <ng-select | ||
2 | [(ngModel)]="selectedId" | ||
3 | (ngModelChange)="onModelChange()" | ||
4 | [bindLabel]="bindLabel" | ||
5 | [bindValue]="bindValue" | ||
6 | [clearable]="clearable" | ||
7 | [searchable]="searchable" | ||
8 | > | ||
9 | <ng-option *ngFor="let channel of channels" [value]="channel.id"> | ||
10 | <img | ||
11 | class="avatar mr-1" | ||
12 | [src]="channel.avatarPath" | ||
13 | /> | ||
14 | {{ channel.label }} | ||
15 | </ng-option> | ||
16 | </ng-select> | ||
diff --git a/client/src/app/shared/shared-forms/select-channel.component.ts b/client/src/app/shared/shared-forms/select-channel.component.ts new file mode 100644 index 000000000..de98c8c0a --- /dev/null +++ b/client/src/app/shared/shared-forms/select-channel.component.ts | |||
@@ -0,0 +1,51 @@ | |||
1 | import { Component, Input, forwardRef, ViewChild } from '@angular/core' | ||
2 | import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms' | ||
3 | import { Actor } from '../shared-main' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-select-channel', | ||
7 | styleUrls: [ './select-shared.component.scss' ], | ||
8 | templateUrl: './select-channel.component.html', | ||
9 | providers: [ | ||
10 | { | ||
11 | provide: NG_VALUE_ACCESSOR, | ||
12 | useExisting: forwardRef(() => SelectChannelComponent), | ||
13 | multi: true | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export class SelectChannelComponent implements ControlValueAccessor { | ||
18 | @Input() items: { id: number, label: string, support: string, avatarPath?: string }[] = [] | ||
19 | |||
20 | selectedId: number | ||
21 | |||
22 | // ng-select options | ||
23 | bindLabel = 'label' | ||
24 | bindValue = 'id' | ||
25 | clearable = false | ||
26 | searchable = false | ||
27 | |||
28 | get channels () { | ||
29 | return this.items.map(c => Object.assign(c, { | ||
30 | avatarPath: c.avatarPath ? c.avatarPath : Actor.GET_DEFAULT_AVATAR_URL() | ||
31 | })) | ||
32 | } | ||
33 | |||
34 | propagateChange = (_: any) => { /* empty */ } | ||
35 | |||
36 | writeValue (id: number) { | ||
37 | this.selectedId = id | ||
38 | } | ||
39 | |||
40 | registerOnChange (fn: (_: any) => void) { | ||
41 | this.propagateChange = fn | ||
42 | } | ||
43 | |||
44 | registerOnTouched () { | ||
45 | // Unused | ||
46 | } | ||
47 | |||
48 | onModelChange () { | ||
49 | this.propagateChange(this.selectedId) | ||
50 | } | ||
51 | } | ||
diff --git a/client/src/app/shared/shared-forms/select-options.component.html b/client/src/app/shared/shared-forms/select-options.component.html new file mode 100644 index 000000000..fda0c2c56 --- /dev/null +++ b/client/src/app/shared/shared-forms/select-options.component.html | |||
@@ -0,0 +1,18 @@ | |||
1 | <ng-select | ||
2 | [items]="items" | ||
3 | [groupBy]="groupBy" | ||
4 | [(ngModel)]="selectedId" | ||
5 | (ngModelChange)="onModelChange()" | ||
6 | [bindLabel]="bindLabel" | ||
7 | [bindValue]="bindValue" | ||
8 | [clearable]="clearable" | ||
9 | [searchable]="searchable" | ||
10 | > | ||
11 | <ng-template ng-option-tmp let-item="item" let-index="index"> | ||
12 | {{ item.label }} | ||
13 | <ng-container *ngIf="item.description"> | ||
14 | <br> | ||
15 | <span [title]="item.description" class="text-muted">{{ item.description }}</span> | ||
16 | </ng-container> | ||
17 | </ng-template> | ||
18 | </ng-select> | ||
diff --git a/client/src/app/shared/shared-forms/select-options.component.ts b/client/src/app/shared/shared-forms/select-options.component.ts new file mode 100644 index 000000000..09f7df53b --- /dev/null +++ b/client/src/app/shared/shared-forms/select-options.component.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import { Component, Input, forwardRef } from '@angular/core' | ||
2 | import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms' | ||
3 | |||
4 | export type SelectOptionsItem = { id: number | string, label: string, description?: string } | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-select-options', | ||
8 | styleUrls: [ './select-shared.component.scss' ], | ||
9 | templateUrl: './select-options.component.html', | ||
10 | providers: [ | ||
11 | { | ||
12 | provide: NG_VALUE_ACCESSOR, | ||
13 | useExisting: forwardRef(() => SelectOptionsComponent), | ||
14 | multi: true | ||
15 | } | ||
16 | ] | ||
17 | }) | ||
18 | export class SelectOptionsComponent implements ControlValueAccessor { | ||
19 | @Input() items: SelectOptionsItem[] = [] | ||
20 | @Input() clearable = false | ||
21 | @Input() searchable = false | ||
22 | @Input() bindValue = 'id' | ||
23 | @Input() groupBy: string | ||
24 | |||
25 | selectedId: number | string | ||
26 | |||
27 | // ng-select options | ||
28 | bindLabel = 'label' | ||
29 | |||
30 | propagateChange = (_: any) => { /* empty */ } | ||
31 | |||
32 | writeValue (id: number | string) { | ||
33 | this.selectedId = id | ||
34 | } | ||
35 | |||
36 | registerOnChange (fn: (_: any) => void) { | ||
37 | this.propagateChange = fn | ||
38 | } | ||
39 | |||
40 | registerOnTouched () { | ||
41 | // Unused | ||
42 | } | ||
43 | |||
44 | onModelChange () { | ||
45 | this.propagateChange(this.selectedId) | ||
46 | } | ||
47 | } | ||
diff --git a/client/src/app/shared/shared-forms/select-shared.component.scss b/client/src/app/shared/shared-forms/select-shared.component.scss new file mode 100644 index 000000000..4f231d28a --- /dev/null +++ b/client/src/app/shared/shared-forms/select-shared.component.scss | |||
@@ -0,0 +1,20 @@ | |||
1 | $width-size: auto; | ||
2 | |||
3 | ng-select { | ||
4 | width: $width-size; | ||
5 | @media screen and (max-width: $width-size) { | ||
6 | width: 100%; | ||
7 | } | ||
8 | } | ||
9 | |||
10 | // make sure the image is vertically adjusted | ||
11 | ng-select ::ng-deep .ng-value-label img { | ||
12 | position: relative; | ||
13 | top: -1px; | ||
14 | } | ||
15 | |||
16 | ng-select ::ng-deep img { | ||
17 | border-radius: 50%; | ||
18 | height: 20px; | ||
19 | width: 20px; | ||
20 | } | ||
diff --git a/client/src/app/shared/shared-forms/select-tags.component.html b/client/src/app/shared/shared-forms/select-tags.component.html new file mode 100644 index 000000000..0609c9d20 --- /dev/null +++ b/client/src/app/shared/shared-forms/select-tags.component.html | |||
@@ -0,0 +1,13 @@ | |||
1 | <ng-select | ||
2 | [items]="items" | ||
3 | [(ngModel)]="_items" | ||
4 | (ngModelChange)="onModelChange()" | ||
5 | i18n-placeholder placeholder="Enter a new tag" | ||
6 | [maxSelectedItems]="5" | ||
7 | [clearable]="true" | ||
8 | [addTag]="true" | ||
9 | [multiple]="true" | ||
10 | [isOpen]="false" | ||
11 | [searchable]="true" | ||
12 | > | ||
13 | </ng-select> | ||
diff --git a/client/src/app/shared/shared-forms/select-tags.component.scss b/client/src/app/shared/shared-forms/select-tags.component.scss new file mode 100644 index 000000000..ad76bc7ee --- /dev/null +++ b/client/src/app/shared/shared-forms/select-tags.component.scss | |||
@@ -0,0 +1,3 @@ | |||
1 | ng-select ::ng-deep .ng-arrow-wrapper { | ||
2 | display: none; | ||
3 | } | ||
diff --git a/client/src/app/shared/shared-forms/select-tags.component.ts b/client/src/app/shared/shared-forms/select-tags.component.ts new file mode 100644 index 000000000..2e07d7e8f --- /dev/null +++ b/client/src/app/shared/shared-forms/select-tags.component.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { Component, Input, forwardRef } from '@angular/core' | ||
2 | import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-select-tags', | ||
6 | styleUrls: [ './select-shared.component.scss', './select-tags.component.scss' ], | ||
7 | templateUrl: './select-tags.component.html', | ||
8 | providers: [ | ||
9 | { | ||
10 | provide: NG_VALUE_ACCESSOR, | ||
11 | useExisting: forwardRef(() => SelectTagsComponent), | ||
12 | multi: true | ||
13 | } | ||
14 | ] | ||
15 | }) | ||
16 | export class SelectTagsComponent implements ControlValueAccessor { | ||
17 | @Input() items: string[] = [] | ||
18 | @Input() _items: string[] = [] | ||
19 | |||
20 | propagateChange = (_: any) => { /* empty */ } | ||
21 | |||
22 | writeValue (items: string[]) { | ||
23 | this._items = items | ||
24 | this.propagateChange(this._items) | ||
25 | } | ||
26 | |||
27 | registerOnChange (fn: (_: any) => void) { | ||
28 | this.propagateChange = fn | ||
29 | } | ||
30 | |||
31 | registerOnTouched () { | ||
32 | // Unused | ||
33 | } | ||
34 | |||
35 | onModelChange () { | ||
36 | this.propagateChange(this._items) | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts index ba33704cf..19d812948 100644 --- a/client/src/app/shared/shared-forms/shared-form.module.ts +++ b/client/src/app/shared/shared-forms/shared-form.module.ts | |||
@@ -28,6 +28,9 @@ import { PreviewUploadComponent } from './preview-upload.component' | |||
28 | import { ReactiveFileComponent } from './reactive-file.component' | 28 | import { ReactiveFileComponent } from './reactive-file.component' |
29 | import { TextareaAutoResizeDirective } from './textarea-autoresize.directive' | 29 | import { TextareaAutoResizeDirective } from './textarea-autoresize.directive' |
30 | import { TimestampInputComponent } from './timestamp-input.component' | 30 | import { TimestampInputComponent } from './timestamp-input.component' |
31 | import { SelectChannelComponent } from './select-channel.component' | ||
32 | import { SelectOptionsComponent } from './select-options.component' | ||
33 | import { SelectTagsComponent } from './select-tags.component' | ||
31 | 34 | ||
32 | @NgModule({ | 35 | @NgModule({ |
33 | imports: [ | 36 | imports: [ |
@@ -45,7 +48,10 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
45 | PreviewUploadComponent, | 48 | PreviewUploadComponent, |
46 | ReactiveFileComponent, | 49 | ReactiveFileComponent, |
47 | TextareaAutoResizeDirective, | 50 | TextareaAutoResizeDirective, |
48 | TimestampInputComponent | 51 | TimestampInputComponent, |
52 | SelectChannelComponent, | ||
53 | SelectOptionsComponent, | ||
54 | SelectTagsComponent | ||
49 | ], | 55 | ], |
50 | 56 | ||
51 | exports: [ | 57 | exports: [ |
@@ -58,7 +64,10 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
58 | PreviewUploadComponent, | 64 | PreviewUploadComponent, |
59 | ReactiveFileComponent, | 65 | ReactiveFileComponent, |
60 | TextareaAutoResizeDirective, | 66 | TextareaAutoResizeDirective, |
61 | TimestampInputComponent | 67 | TimestampInputComponent, |
68 | SelectChannelComponent, | ||
69 | SelectOptionsComponent, | ||
70 | SelectTagsComponent | ||
62 | ], | 71 | ], |
63 | 72 | ||
64 | providers: [ | 73 | providers: [ |
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 22a207e51..a4d18d562 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts | |||
@@ -17,6 +17,7 @@ import { | |||
17 | NgbPopoverModule, | 17 | NgbPopoverModule, |
18 | NgbTooltipModule | 18 | NgbTooltipModule |
19 | } from '@ng-bootstrap/ng-bootstrap' | 19 | } from '@ng-bootstrap/ng-bootstrap' |
20 | import { NgSelectModule } from '@ng-select/ng-select' | ||
20 | import { I18n } from '@ngx-translate/i18n-polyfill' | 21 | import { I18n } from '@ngx-translate/i18n-polyfill' |
21 | import { SharedGlobalIconModule } from '../shared-icons' | 22 | import { SharedGlobalIconModule } from '../shared-icons' |
22 | import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' | 23 | import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' |
@@ -55,6 +56,8 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | |||
55 | MultiSelectModule, | 56 | MultiSelectModule, |
56 | InputSwitchModule, | 57 | InputSwitchModule, |
57 | 58 | ||
59 | NgSelectModule, | ||
60 | |||
58 | SharedGlobalIconModule | 61 | SharedGlobalIconModule |
59 | ], | 62 | ], |
60 | 63 | ||
@@ -134,7 +137,9 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | |||
134 | TopMenuDropdownComponent, | 137 | TopMenuDropdownComponent, |
135 | 138 | ||
136 | UserQuotaComponent, | 139 | UserQuotaComponent, |
137 | UserNotificationsComponent | 140 | UserNotificationsComponent, |
141 | |||
142 | NgSelectModule | ||
138 | ], | 143 | ], |
139 | 144 | ||
140 | providers: [ | 145 | providers: [ |
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index edaefa9f2..978f775bf 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts | |||
@@ -339,23 +339,25 @@ export class VideoService implements VideosProvider { | |||
339 | const base = [ | 339 | const base = [ |
340 | { | 340 | { |
341 | id: VideoPrivacy.PRIVATE, | 341 | id: VideoPrivacy.PRIVATE, |
342 | label: this.i18n('Only I can see this video') | 342 | description: this.i18n('Only I can see this video') |
343 | }, | 343 | }, |
344 | { | 344 | { |
345 | id: VideoPrivacy.UNLISTED, | 345 | id: VideoPrivacy.UNLISTED, |
346 | label: this.i18n('Only people with the private link can see this video') | 346 | description: this.i18n('Only shareable via a private link') |
347 | }, | 347 | }, |
348 | { | 348 | { |
349 | id: VideoPrivacy.PUBLIC, | 349 | id: VideoPrivacy.PUBLIC, |
350 | label: this.i18n('Anyone can see this video') | 350 | description: this.i18n('Anyone can see this video') |
351 | }, | 351 | }, |
352 | { | 352 | { |
353 | id: VideoPrivacy.INTERNAL, | 353 | id: VideoPrivacy.INTERNAL, |
354 | label: this.i18n('Only users of this instance can see this video') | 354 | description: this.i18n('Only users of this instance can see this video') |
355 | } | 355 | } |
356 | ] | 356 | ] |
357 | 357 | ||
358 | return base.filter(o => !!privacies.find(p => p.id === o.id)) | 358 | return base |
359 | .filter(o => !!privacies.find(p => p.id === o.id)) // filter down to privacies that where in the input | ||
360 | .map(o => ({ ...privacies[o.id - 1], ...o })) // merge the input privacies that contain a label, and extend them with a description | ||
359 | } | 361 | } |
360 | 362 | ||
361 | nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) { | 363 | nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) { |