diff --git a/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.spec.ts new file mode 100644 index 00000000..05e69040 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChevronIconComponent } from './chevron-icon.component'; + +describe('ChevronIconComponent', () => { + let component: ChevronIconComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChevronIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ChevronIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.ts new file mode 100644 index 00000000..7c7e4b28 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/chevron-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-chevron-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-chevron-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class ChevronIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/chevron-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/index.ts new file mode 100644 index 00000000..2df614da --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/chevron-icon/index.ts @@ -0,0 +1 @@ +export * from './chevron-icon.component'; diff --git a/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.spec.ts new file mode 100644 index 00000000..dda820c4 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ClipboardCheckIconComponent } from './clipboard-check-icon.component'; + +describe('ClipboardCheckIconComponent', () => { + let component: ClipboardCheckIconComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ClipboardCheckIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ClipboardCheckIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.ts new file mode 100644 index 00000000..9c8dddcd --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/clipboard-check-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-clipboard-check-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-clipboard-check-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class ClipboardCheckIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/index.ts new file mode 100644 index 00000000..4ed5abd0 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-check-icon/index.ts @@ -0,0 +1 @@ +export * from './clipboard-check-icon.component'; diff --git a/src/frontend/src/app/core/ui/loading-button/loading-button.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.spec.ts similarity index 51% rename from src/frontend/src/app/core/ui/loading-button/loading-button.component.spec.ts rename to lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.spec.ts index 085c2d08..c7a47d27 100644 --- a/src/frontend/src/app/core/ui/loading-button/loading-button.component.spec.ts +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LoadingButtonComponent } from './loading-button.component'; +import { ClipboardIconComponent } from './clipboard-icon.component'; -describe('LoadingButtonComponent', () => { - let component: LoadingButtonComponent; - let fixture: ComponentFixture; +describe('ClipboardIconComponent', () => { + let component: ClipboardIconComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LoadingButtonComponent], + imports: [ClipboardIconComponent], }).compileComponents(); - fixture = TestBed.createComponent(LoadingButtonComponent); + fixture = TestBed.createComponent(ClipboardIconComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.ts new file mode 100644 index 00000000..ca8c8b37 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/clipboard-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-clipboard-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-clipboard-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class ClipboardIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/index.ts new file mode 100644 index 00000000..b3aa7c1b --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/clipboard-icon/index.ts @@ -0,0 +1 @@ +export * from './clipboard-icon.component'; diff --git a/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.spec.ts new file mode 100644 index 00000000..2c9d18b0 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CloseIconComponent } from './close-icon.component'; + +describe('CloseIconComponent', () => { + let component: CloseIconComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CloseIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CloseIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.ts new file mode 100644 index 00000000..a796516a --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/close-icon/close-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-close-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-close-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class CloseIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/close-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/close-icon/index.ts new file mode 100644 index 00000000..4bb58254 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/close-icon/index.ts @@ -0,0 +1 @@ +export * from './close-icon.component'; diff --git a/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.spec.ts new file mode 100644 index 00000000..5f9ea04f --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HamburgerIconComponent } from './hamburger-icon.component'; + +describe('HamburgerIconComponent', () => { + let component: HamburgerIconComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HamburgerIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HamburgerIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.ts new file mode 100644 index 00000000..6753ef81 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/hamburger-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-hamburger-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-hamburger-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class HamburgerIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/index.ts new file mode 100644 index 00000000..1f414ee1 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/hamburger-icon/index.ts @@ -0,0 +1 @@ +export * from './hamburger-icon.component'; diff --git a/lib/angular-ui/src/lib/atoms/icons/index.ts b/lib/angular-ui/src/lib/atoms/icons/index.ts index eef3172b..476151cc 100644 --- a/lib/angular-ui/src/lib/atoms/icons/index.ts +++ b/lib/angular-ui/src/lib/atoms/icons/index.ts @@ -1,3 +1,10 @@ export * from './check-circle-icon/index'; +export * from './chevron-icon/index'; +export * from './clipboard-check-icon/index'; +export * from './clipboard-icon/index'; +export * from './close-icon/index'; export * from './dash-circle-icon/index'; +export * from './hamburger-icon/index'; +export * from './loading-icon/index'; +export * from './logo-icon/index'; export * from './profile-icon/index'; diff --git a/lib/angular-ui/src/lib/atoms/icons/loading-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/loading-icon/index.ts new file mode 100644 index 00000000..324d0180 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/loading-icon/index.ts @@ -0,0 +1 @@ +export * from './loading-icon.component'; diff --git a/src/frontend/src/app/core/icons/loading-icon/loading-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.spec.ts similarity index 89% rename from src/frontend/src/app/core/icons/loading-icon/loading-icon.component.spec.ts rename to lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.spec.ts index 9d23bf92..e8d78958 100644 --- a/src/frontend/src/app/core/icons/loading-icon/loading-icon.component.spec.ts +++ b/lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LoadingIconComponent } from './loading-icon-component'; +import { LoadingIconComponent } from './loading-icon.component'; describe('LoadingIconComponent', () => { let component: LoadingIconComponent; diff --git a/lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.ts new file mode 100644 index 00000000..b00d91cf --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/loading-icon/loading-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-loading-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-loading-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class LoadingIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/icons/logo-icon/index.ts b/lib/angular-ui/src/lib/atoms/icons/logo-icon/index.ts new file mode 100644 index 00000000..fed9fbd7 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/logo-icon/index.ts @@ -0,0 +1 @@ +export * from './logo-icon.component'; diff --git a/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.spec.ts b/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.spec.ts new file mode 100644 index 00000000..eac4fd86 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LogoIconComponent } from './logo-icon.component'; + +describe('LogoIconComponent', () => { + let component: LogoIconComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LogoIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LogoIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.ts b/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.ts new file mode 100644 index 00000000..b6b57361 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/icons/logo-icon/logo-icon.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-logo-icon'; +import { DefineCustomElement } from '../../../define-custom-element'; + +@DefineCustomElement(defineCustomElement) +@Component({ + selector: 'cg-logo-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class LogoIconComponent {} diff --git a/lib/angular-ui/src/lib/atoms/index.ts b/lib/angular-ui/src/lib/atoms/index.ts index 2c8e5b7c..d52b5b60 100644 --- a/lib/angular-ui/src/lib/atoms/index.ts +++ b/lib/angular-ui/src/lib/atoms/index.ts @@ -2,5 +2,6 @@ export * from './badge/index'; export * from './card/index'; export * from './icon-btn/index'; export * from './icons/index'; +export * from './link-text-btn/index'; export * from './radio-input/index'; export * from './text-btn/index'; diff --git a/lib/angular-ui/src/lib/atoms/link-text-btn/index.ts b/lib/angular-ui/src/lib/atoms/link-text-btn/index.ts new file mode 100644 index 00000000..8439f491 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/link-text-btn/index.ts @@ -0,0 +1 @@ +export * from './link-text-btn.component'; diff --git a/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.spec.ts b/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.spec.ts new file mode 100644 index 00000000..35fbb97b --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LinkTextBtnComponent } from './link-text-btn.component'; + +describe('LinkTextBtnComponent', () => { + let component: LinkTextBtnComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LinkTextBtnComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LinkTextBtnComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.ts b/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.ts new file mode 100644 index 00000000..eced30e7 --- /dev/null +++ b/lib/angular-ui/src/lib/atoms/link-text-btn/link-text-btn.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { defineCustomLinkElementComponent } from '../../custom-element-component'; +import { defineCustomElement } from '@cg/ui/dist/components/cg-link-text-btn'; + +type CustomElement = HTMLCgLinkTextBtnElement; + +@Component({ + selector: 'cg-link-text-btn', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class LinkTextBtnComponent extends defineCustomLinkElementComponent( + defineCustomElement, +) { + public readonly routerLink = input(); + public readonly href = input(); + public readonly isExternal = input(false); +} diff --git a/lib/angular-ui/src/lib/atoms/text-btn/text-btn.component.ts b/lib/angular-ui/src/lib/atoms/text-btn/text-btn.component.ts index f66ec76b..ae9ee63f 100644 --- a/lib/angular-ui/src/lib/atoms/text-btn/text-btn.component.ts +++ b/lib/angular-ui/src/lib/atoms/text-btn/text-btn.component.ts @@ -1,12 +1,32 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { defineCustomElement } from '@cg/ui/dist/components/cg-text-btn'; -import { DefineCustomElement } from '../../define-custom-element'; +import { defineCustomElementComponent } from '../../custom-element-component'; +import { AriaHasPopup, ButtonType, Theme } from '@cg/ui'; -@DefineCustomElement(defineCustomElement) @Component({ selector: 'cg-text-btn', changeDetection: ChangeDetectionStrategy.OnPush, template: ``, }) -export class TextBtnComponent {} +export class TextBtnComponent extends defineCustomElementComponent( + defineCustomElement, +) { + public readonly type = input('button'); + public readonly theme = input(); + public readonly disabled = input(); + public readonly ariaHasPopup = input(); + public readonly ariaExpanded = input(); + public readonly ariaControls = input(); + + constructor() { + super(); + + this.elemProxyEffect(this.type, 'type'); + this.elemProxyEffect(this.theme, 'theme'); + this.elemProxyEffect(this.disabled, 'disabled'); + this.elemProxyEffect(this.ariaHasPopup, 'ariaHasPopup'); + this.elemProxyEffect(this.ariaExpanded, 'ariaExpanded'); + this.elemProxyEffect(this.ariaControls, 'ariaControls'); + } +} diff --git a/lib/angular-ui/src/lib/custom-element-component.ts b/lib/angular-ui/src/lib/custom-element-component.ts new file mode 100644 index 00000000..d205beb1 --- /dev/null +++ b/lib/angular-ui/src/lib/custom-element-component.ts @@ -0,0 +1,115 @@ +import { + Component, + effect, + EffectRef, + ElementRef, + HostBinding, + HostListener, + inject, + input, + InputSignal, + NgZone, +} from '@angular/core'; +import { DefineCustomElement } from './define-custom-element'; + +export function defineCustomElementComponent( + defineCustomElement: () => void, +) { + @DefineCustomElement(defineCustomElement) + class CustomElementComponent { + public readonly ngZone = inject(NgZone); + public readonly elementRef = inject>(ElementRef); + + public elemProxyEffect( + input: InputSignal, + property: K, + ): EffectRef { + return effect(() => { + const value = input(); + + this.ngZone.runOutsideAngular(() => { + this.elementRef.nativeElement[property] = value; + }); + }); + } + } + + return CustomElementComponent; +} + +export function defineCustomLinkElementComponent< + T extends { href: string; isExternal?: boolean }, +>(defineCustomElement: () => void) { + @Component({ template: '' }) + class CustomLinkElementComponent extends defineCustomElementComponent( + defineCustomElement, + ) { + public readonly routerLink = input(); + public readonly href = input(); + public readonly isExternal = input(false); + + // prevent the `href` from being followed when clicked if it's not explicitly set. + // this happens when `routerLink` is set. + @HostListener('click', ['$event']) + public onClick(event: Event): void { + if (!this.hasExplicitHref) { + event.preventDefault(); + } + } + + // prevent the host element from being focused. + // this happens when `routerLink` is set. + @HostBinding('attr.tabindex') + public tabIndex = -1; + + public hasExplicitHref = false; + + constructor() { + super(); + + // when `routerLink` is set, proxy the value to the `href` attribute + // for accessibility. + effect(() => { + const routerLink = this.routerLink(); + + if (routerLink) { + this.ngZone.runOutsideAngular(() => { + const href = + typeof routerLink === 'string' + ? routerLink + : routerLink.join('/'); + + this.elementRef.nativeElement.href = href; + }); + } + }); + + effect(() => { + // see onClick for why this is necessary + this.hasExplicitHref = true; + const href = this.href(); + + if (href) { + this.ngZone.runOutsideAngular(() => { + this.elementRef.nativeElement.href = href; + }); + } + }); + } + + public elemProxyEffect( + input: InputSignal, + property: K, + ): EffectRef { + return effect(() => { + const value = input(); + + this.ngZone.runOutsideAngular(() => { + this.elementRef.nativeElement[property] = value; + }); + }); + } + } + + return CustomLinkElementComponent; +} diff --git a/lib/angular-ui/src/lib/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.component.ts b/lib/angular-ui/src/lib/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.component.ts index 6c7e120e..87528280 100644 --- a/lib/angular-ui/src/lib/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.component.ts +++ b/lib/angular-ui/src/lib/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.component.ts @@ -1,82 +1,15 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - HostBinding, - HostListener, - NgZone, - effect, - input, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { defineCustomElement } from '@cg/ui/dist/components/cg-dropdown-link-menu-item'; -import { DefineCustomElement } from '../../../define-custom-element'; +import { defineCustomLinkElementComponent } from '../../../custom-element-component'; + +type CustomElement = HTMLCgLinkTextBtnElement; -@DefineCustomElement(defineCustomElement) @Component({ selector: 'cg-dropdown-link-menu-item', changeDetection: ChangeDetectionStrategy.OnPush, template: ``, }) -export class DropdownLinkMenuItemComponent { - public readonly routerLink = - input(); - - // prevent the `href` from being followed when clicked if it's not explicitly set. - // this happens when `routerLink` is set. - @HostListener('click', ['$event']) - public onClick(event: Event): void { - if (!this.hasExplicitHref) { - event.preventDefault(); - } - } - - // prevent the host element from being focused. - // this happens when `routerLink` is set. - @HostBinding('attr.tabindex') - public tabIndex = -1; - - public href = input(); - - public isExternal = - input(false); - - private hasExplicitHref = false; - - constructor( - private readonly ngZone: NgZone, - private readonly elementRef: ElementRef, - ) { - // when `routerLink` is set, proxy the value to the `href` attribute - // for accessibility. - effect(() => { - const routerLink = this.routerLink(); - - if (routerLink) { - this.ngZone.runOutsideAngular(() => { - this.elementRef.nativeElement.href = routerLink; - }); - } - }); - - effect(() => { - // see onClick for why this is necessary - this.hasExplicitHref = true; - const href = this.href(); - - if (href) { - this.ngZone.runOutsideAngular(() => { - this.elementRef.nativeElement.href = href; - }); - } - }); - - effect(() => { - const isExternal = this.isExternal(); - - this.ngZone.runOutsideAngular(() => { - this.elementRef.nativeElement.isExternal = isExternal; - }); - }); - } -} +export class DropdownLinkMenuItemComponent extends defineCustomLinkElementComponent( + defineCustomElement, +) {} diff --git a/lib/angular-ui/src/lib/molecules/image-uploader-btn/image-uploader-btn.component.ts b/lib/angular-ui/src/lib/molecules/image-uploader-btn/image-uploader-btn.component.ts index 982c0e56..80e81b63 100644 --- a/lib/angular-ui/src/lib/molecules/image-uploader-btn/image-uploader-btn.component.ts +++ b/lib/angular-ui/src/lib/molecules/image-uploader-btn/image-uploader-btn.component.ts @@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, HostListener, + input, output, } from '@angular/core'; import { defineCustomElement } from '@cg/ui/dist/components/cg-image-uploader-btn'; -import { DefineCustomElement } from '../../define-custom-element'; +import { defineCustomElementComponent } from '../../custom-element-component'; export { ImageSet } from '@cg/ui'; @@ -14,17 +15,28 @@ type ImagesSelectedEvent = CustomEvent< HTMLCgImageUploaderBtnElementEventMap['imagesSelected'] >; -@DefineCustomElement(defineCustomElement) @Component({ selector: 'cg-image-uploader-btn', changeDetection: ChangeDetectionStrategy.OnPush, template: ``, }) -export class ImageUploaderBtnComponent { +export class ImageUploaderBtnComponent extends defineCustomElementComponent( + defineCustomElement, +) { + public readonly disabled = input(); + public readonly isLoading = input(); + @HostListener('imagesSelected', ['$event']) public onImagesSelected(event: ImagesSelectedEvent): void { this.selectedImagesChange.emit(event.detail); } public selectedImagesChange = output(); + + constructor() { + super(); + + this.elemProxyEffect(this.disabled, 'disabled'); + this.elemProxyEffect(this.isLoading, 'isLoading'); + } } diff --git a/lib/angular-ui/src/lib/molecules/index.ts b/lib/angular-ui/src/lib/molecules/index.ts index 1a74cf12..eebdb9ff 100644 --- a/lib/angular-ui/src/lib/molecules/index.ts +++ b/lib/angular-ui/src/lib/molecules/index.ts @@ -2,3 +2,4 @@ export * from './collapsible/index'; export * from './copy-to-clipboard/index'; export * from './dropdown/index'; export * from './image-uploader-btn/index'; +export * from './loading-btn/index'; diff --git a/lib/angular-ui/src/lib/molecules/loading-btn/index.ts b/lib/angular-ui/src/lib/molecules/loading-btn/index.ts new file mode 100644 index 00000000..32d72bc3 --- /dev/null +++ b/lib/angular-ui/src/lib/molecules/loading-btn/index.ts @@ -0,0 +1 @@ +export * from './loading-btn.component'; diff --git a/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.spec.ts b/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.spec.ts new file mode 100644 index 00000000..863dcfd7 --- /dev/null +++ b/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoadingBtnComponent } from './loading-btn.component'; + +describe('LoadingBtnComponent', () => { + let component: LoadingBtnComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoadingBtnComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LoadingBtnComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.ts b/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.ts new file mode 100644 index 00000000..79fb8dc9 --- /dev/null +++ b/lib/angular-ui/src/lib/molecules/loading-btn/loading-btn.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { defineCustomElement } from '@cg/ui/dist/components/cg-loading-btn'; +import { ButtonType, Theme } from '@cg/ui'; +import { defineCustomElementComponent } from '../../custom-element-component'; + +@Component({ + selector: 'cg-loading-btn', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class LoadingBtnComponent extends defineCustomElementComponent( + defineCustomElement, +) { + public readonly type = input('button'); + public readonly theme = input(); + public readonly isLoading = input(); + public readonly disabled = input(); + + constructor() { + super(); + + this.elemProxyEffect(this.type, 'type'); + this.elemProxyEffect(this.theme, 'theme'); + this.elemProxyEffect(this.isLoading, 'isLoading'); + this.elemProxyEffect(this.disabled, 'disabled'); + } +} diff --git a/lib/styles/common/buttons.scss b/lib/styles/common/buttons.scss index a8a74584..34f8d01e 100644 --- a/lib/styles/common/buttons.scss +++ b/lib/styles/common/buttons.scss @@ -20,23 +20,79 @@ color: colors.$slate-300; } - @include effects.quick-transition(background-color); - - &:hover { + &:hover:not(:disabled) { + color: colors.$slate-800; background-color: colors.$slate-200; @include dark-mode.dark { + color: colors.$slate-200; background-color: colors.$slate-800; } } - &:active { + &:active:not(:disabled) { background-color: colors.$slate-300; @include dark-mode.dark { background-color: colors.$slate-700; } } + + &:disabled { + color: rgba(colors.$slate-900, 0.45); + @include dark-mode.dark { + color: rgba(colors.$slate-300, 0.45); + } + + &:hover { + cursor: not-allowed; + } + } +} + +@mixin transparent-btn--primary { + &:not(:disabled) { + color: colors.$primary; + } + + &:hover:not(:disabled) { + color: colors.$slate-200; + background-color: colors.$primary; + } + + &:active:not(:disabled) { + background-color: colors.$primary-dark; + } +} + +@mixin transparent-btn--success { + &:not(:disabled) { + color: colors.$success; + } + + &:hover:not(:disabled) { + color: colors.$slate-200; + background-color: colors.$success; + } + + &:active:not(:disabled) { + background-color: colors.$success-dark; + } +} + +@mixin transparent-btn--error { + &:not(:disabled) { + color: colors.$error; + } + + &:hover:not(:disabled) { + color: colors.$slate-200; + background-color: colors.$error; + } + + &:active:not(:disabled) { + background-color: colors.$error-dark; + } } @mixin rectangular-btn { diff --git a/lib/styles/common/colors.scss b/lib/styles/common/colors.scss index 7eadfc21..04d9fa2f 100644 --- a/lib/styles/common/colors.scss +++ b/lib/styles/common/colors.scss @@ -1,7 +1,11 @@ $white: #ffffff; $black: #000000; + $error: #f43f53; +$error-dark: #a30c1b; + $success: #099621; +$success-dark: #0a7d0a; $slate: #64748b; @@ -18,6 +22,7 @@ $slate-900: #0f172a; $slate-950: #020617; $primary: #29abe2; +$primary-dark: #0e6896; $primary-50: #f1f9fe; $primary-100: #e2f2fc; @@ -26,7 +31,7 @@ $primary-300: #86d1f3; $primary-400: #46baea; $primary-500: $primary; $primary-600: #1082b9; -$primary-700: #0e6896; +$primary-700: $primary-dark; $primary-800: #10577c; $primary-900: #134967; $primary-950: #0d2f44; diff --git a/lib/styles/global/buttons.scss b/lib/styles/global/buttons.scss index c5c3dd9f..2ca021fe 100644 --- a/lib/styles/global/buttons.scss +++ b/lib/styles/global/buttons.scss @@ -86,11 +86,3 @@ $btn-disabled-color: rgba(common.$primary, 0.45); margin-right: common.size(2); } } - -.btn--loading { - position: absolute; - left: 50%; - top: 50%; - @include common.icon-xxl; - transform: translate(-50%, -50%); -} diff --git a/lib/styles/global/icons.scss b/lib/styles/global/icons.scss index e2c9b3a6..4c171597 100644 --- a/lib/styles/global/icons.scss +++ b/lib/styles/global/icons.scss @@ -3,7 +3,6 @@ .icon { width: 100%; height: 100%; - fill: currentColor; vertical-align: baseline; } diff --git a/lib/ui/.storybook/preview.ts b/lib/ui/.storybook/preview.ts index a51aeb6d..445de600 100644 --- a/lib/ui/.storybook/preview.ts +++ b/lib/ui/.storybook/preview.ts @@ -8,6 +8,7 @@ const preview: Preview = { parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { + exclude: ['theme'], matchers: { color: /(background|color)$/i, date: /Date$/i, diff --git a/lib/ui/src/components/atoms/badge/badge.stories.ts b/lib/ui/src/components/atoms/badge/badge.stories.ts index 7ef5532c..3ccf65bc 100644 --- a/lib/ui/src/components/atoms/badge/badge.stories.ts +++ b/lib/ui/src/components/atoms/badge/badge.stories.ts @@ -1,21 +1,24 @@ import { Meta, StoryObj } from '@storybook/html'; +import { Theme } from '../../../types'; -const meta: Meta = { +interface Args { + content: string; + theme: Theme; +} + +const meta: Meta = { title: 'Atoms/Badges', - args: { - content: 'Approved', - }, argTypes: { content: { + name: 'Content', control: { type: 'text' }, }, - theme: { - control: { type: 'select' }, - options: ['primary', 'success', 'error'], - }, + }, + args: { + content: 'Approved', }, render: args => ` - + ${args.content} `, @@ -23,8 +26,20 @@ const meta: Meta = { export default meta; -export const Default: StoryObj = { +export const Primary: StoryObj = { args: { theme: 'primary', }, }; + +export const Success: StoryObj = { + args: { + theme: 'success', + }, +}; + +export const Error: StoryObj = { + args: { + theme: 'error', + }, +}; diff --git a/lib/ui/src/components/atoms/card/card.stories.ts b/lib/ui/src/components/atoms/card/card.stories.ts index d1c96475..9cd3c700 100644 --- a/lib/ui/src/components/atoms/card/card.stories.ts +++ b/lib/ui/src/components/atoms/card/card.stories.ts @@ -2,18 +2,20 @@ import { Meta, StoryObj } from '@storybook/html'; const meta: Meta = { title: 'Atoms/Cards', - args: { - title: 'Hello, World!', - content: 'How are you today?', - }, argTypes: { title: { + name: 'Title', control: { type: 'text' }, }, content: { + name: 'Content', control: { type: 'text' }, }, }, + args: { + title: 'Hello, World!', + content: 'How are you today?', + }, render: args => `
${args.title}
diff --git a/lib/ui/src/components/atoms/focus-ring/focus-ring.scss b/lib/ui/src/components/atoms/focus-ring/focus-ring.scss index d85f67fc..8294570a 100644 --- a/lib/ui/src/components/atoms/focus-ring/focus-ring.scss +++ b/lib/ui/src/components/atoms/focus-ring/focus-ring.scss @@ -1,7 +1,7 @@ @use '~@cg/styles/common'; $focus-ring-width: 3px; -$focus-ring-color: common.$primary-700; +$focus-ring-color: common.$primary-dark; :host-context(.focus-ring) { border-radius: inherit; @@ -20,3 +20,11 @@ $focus-ring-color: common.$primary-700; :host-context(.focus-ring--visible) { opacity: 1; } + +:host-context(.focus-ring--success) { + box-shadow: 0 0 0 $focus-ring-width common.$success; +} + +:host-context(.focus-ring--error) { + box-shadow: 0 0 0 $focus-ring-width common.$error; +} diff --git a/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx b/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx index a1381c03..404ae8b7 100644 --- a/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx +++ b/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx @@ -1,4 +1,5 @@ import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; +import { Theme } from '../../../types'; @Component({ tag: 'cg-focus-ring', @@ -7,14 +8,19 @@ import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; }) export class FocusRingComponent implements ComponentInterface { @Prop({ reflect: true }) - public isFocused = false; + public isFocused?: boolean; + + @Prop({ reflect: true }) + public theme?: Theme; public render() { return ( ); diff --git a/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts b/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts index e6edc3de..ebaf2d42 100644 --- a/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts +++ b/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts @@ -6,12 +6,17 @@ const meta: Meta = { ariaLabel: { control: { type: 'text' }, }, + disabled: { + name: 'Disabled', + control: { type: 'boolean' }, + }, }, args: { ariaLabel: 'Login', + disabled: false, }, render: args => ` - + `, diff --git a/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx b/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx index 2ff2396c..73a56533 100644 --- a/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx +++ b/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx @@ -10,6 +10,9 @@ export class IconBtnComponent implements ComponentInterface { @Prop({ reflect: true }) public type: ButtonType = 'button'; + @Prop({ reflect: true }) + public disabled?: boolean; + @Prop({ reflect: true, attribute: 'aria-label' }) public ariaLabel!: string; @@ -30,6 +33,7 @@ export class IconBtnComponent implements ComponentInterface { diff --git a/lib/ui/src/components/atoms/text-input/text-input.stories.ts b/lib/ui/src/components/atoms/text-input/text-input.stories.ts index 77dc5cb8..1930e704 100644 --- a/lib/ui/src/components/atoms/text-input/text-input.stories.ts +++ b/lib/ui/src/components/atoms/text-input/text-input.stories.ts @@ -4,6 +4,7 @@ const meta: Meta = { title: 'Atoms/Text Inputs', argTypes: { content: { + name: 'Content', control: { type: 'text' }, }, }, diff --git a/lib/ui/src/components/atoms/text-input/text-input.tsx b/lib/ui/src/components/atoms/text-input/text-input.tsx index e2e0b3d9..5a59b5d4 100644 --- a/lib/ui/src/components/atoms/text-input/text-input.tsx +++ b/lib/ui/src/components/atoms/text-input/text-input.tsx @@ -6,6 +6,7 @@ import { Prop, Method, Host, + ComponentInterface, } from '@stencil/core'; @Component({ @@ -14,7 +15,7 @@ import { formAssociated: true, scoped: true, }) -export class TextInput { +export class TextInput implements ComponentInterface { @Prop({ reflect: true }) public value?: string; @@ -22,7 +23,10 @@ export class TextInput { public placeholder?: string; @Prop({ reflect: true }) - public readonly = false; + public readonly?: boolean; + + @Prop({ reflect: true }) + public disabled?: boolean; @State() private isFocused = false; @@ -52,6 +56,7 @@ export class TextInput { value={this.value} placeholder={this.placeholder} readOnly={this.readonly} + disabled={this.disabled} ref={elem => this.setInputElem(elem)} onInput={event => this.handleChange(event)} onFocus={() => this.onFocused()} diff --git a/lib/ui/src/components/molecules/collapsible/collapsible.stories.ts b/lib/ui/src/components/molecules/collapsible/collapsible.stories.ts index 98c029a0..bf90420d 100644 --- a/lib/ui/src/components/molecules/collapsible/collapsible.stories.ts +++ b/lib/ui/src/components/molecules/collapsible/collapsible.stories.ts @@ -3,7 +3,12 @@ import { Meta, StoryObj } from '@storybook/html'; const meta: Meta = { title: 'Molecules/Collapsible', argTypes: { + title: { + name: 'Title', + control: { type: 'text' }, + }, content: { + name: 'Content', control: { type: 'text' }, }, }, diff --git a/lib/ui/src/components/molecules/copy-to-clipboard/copy-to-clipboard.stories.ts b/lib/ui/src/components/molecules/copy-to-clipboard/copy-to-clipboard.stories.ts index 6147a909..6ed3972b 100644 --- a/lib/ui/src/components/molecules/copy-to-clipboard/copy-to-clipboard.stories.ts +++ b/lib/ui/src/components/molecules/copy-to-clipboard/copy-to-clipboard.stories.ts @@ -9,13 +9,16 @@ const meta: Meta = { }, type: { name: 'Type', - control: { type: 'select' }, + control: { + type: 'select', + labels: { text: 'Text', textarea: 'Text Area' }, + }, options: ['text', 'textarea'], }, }, args: { content: 'Super secret code', - inputType: 'text', + type: 'text', }, render: args => ` ; diff --git a/lib/ui/src/components/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.tsx b/lib/ui/src/components/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.tsx index 46a80f02..fd0504e5 100644 --- a/lib/ui/src/components/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.tsx +++ b/lib/ui/src/components/molecules/dropdown/dropdown-link-menu-item/dropdown-link-menu-item.tsx @@ -1,5 +1,6 @@ import { Component, + ComponentInterface, Event, EventEmitter, Listen, @@ -13,12 +14,12 @@ import { styleUrl: 'dropdown-link-menu-item.scss', scoped: true, }) -export class DropdownLinkMenuItemComponent { +export class DropdownLinkMenuItemComponent implements ComponentInterface { @Prop({ reflect: true }) public href!: string; @Prop({ reflect: true }) - public isExternal? = false; + public isExternal?: boolean; @Event() public menuItemClick!: EventEmitter; diff --git a/lib/ui/src/components/molecules/dropdown/dropdown-menu/dropdown-menu.tsx b/lib/ui/src/components/molecules/dropdown/dropdown-menu/dropdown-menu.tsx index da06a6c9..30715164 100644 --- a/lib/ui/src/components/molecules/dropdown/dropdown-menu/dropdown-menu.tsx +++ b/lib/ui/src/components/molecules/dropdown/dropdown-menu/dropdown-menu.tsx @@ -1,11 +1,11 @@ -import { Component, Host, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; @Component({ tag: 'cg-dropdown-menu', styleUrl: 'dropdown-menu.scss', scoped: true, }) -export class MenuComponent { +export class MenuComponent implements ComponentInterface { @Prop({ reflect: true }) public menuId?: string; diff --git a/lib/ui/src/components/molecules/dropdown/dropdown-trigger/dropdown-trigger.tsx b/lib/ui/src/components/molecules/dropdown/dropdown-trigger/dropdown-trigger.tsx index 9fd970b7..c0a3a841 100644 --- a/lib/ui/src/components/molecules/dropdown/dropdown-trigger/dropdown-trigger.tsx +++ b/lib/ui/src/components/molecules/dropdown/dropdown-trigger/dropdown-trigger.tsx @@ -52,7 +52,7 @@ export class DropdownTriggerComponent implements ComponentInterface { @Event() public dropdownTriggerClick!: EventEmitter; - private btnElem!: HTMLCgTextBtnElement | HTMLCgIconBtnElement; + private btnElem?: HTMLCgTextBtnElement | HTMLCgIconBtnElement; @Listen('click') public onClick(): void { @@ -97,12 +97,6 @@ export class DropdownTriggerComponent implements ComponentInterface { private setBtnElem( btnElem?: HTMLCgTextBtnElement | HTMLCgIconBtnElement, ): void { - if (!btnElem) { - throw new Error( - '`cg-dropdown-trigger` must have a `button` child element', - ); - } - if (btnElem !== this.btnElem) { this.btnElem = btnElem; this.setBtnAttributes(); @@ -110,6 +104,11 @@ export class DropdownTriggerComponent implements ComponentInterface { } private setBtnAttributes(): void { + // [TODO]: Use isNil from @cg/utils + if (!this.btnElem) { + return; + } + this.btnElem.setAttribute('aria-haspopup', 'menu'); this.setBtnIsOpen(); @@ -118,20 +117,30 @@ export class DropdownTriggerComponent implements ComponentInterface { } private setBtnIsOpen(): void { - const isOpen = this.isOpen ?? false; + // [TODO]: Use isNil from @cg/utils + if (!this.btnElem) { + return; + } + const isOpen = this.isOpen ?? false; this.btnElem.setAttribute('aria-expanded', isOpen.toString()); } private setBtnId(): void { - if (this.triggerId) { - this.btnElem.id = this.triggerId; + // [TODO]: Use isNil from @cg/utils + if (!this.btnElem || !this.triggerId) { + return; } + + this.btnElem.id = this.triggerId; } private setBtnControls(): void { - if (this.menuId) { - this.btnElem.setAttribute('aria-controls', this.menuId); + // [TODO]: Use isNil from @cg/utils + if (!this.btnElem || !this.menuId) { + return; } + + this.btnElem.setAttribute('aria-controls', this.menuId); } } diff --git a/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.stories.ts b/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.stories.ts index 496418ae..f128e538 100644 --- a/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.stories.ts +++ b/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.stories.ts @@ -2,9 +2,31 @@ import { Meta, StoryObj } from '@storybook/html'; const meta: Meta = { title: 'Molecules/Image Uploader Button', - render: () => ` - - Select image(s) + argTypes: { + content: { + name: 'Content', + control: { type: 'text' }, + }, + disabled: { + name: 'Disabled', + control: { type: 'boolean' }, + }, + isLoading: { + name: 'Loading', + control: { type: 'boolean' }, + }, + }, + args: { + content: 'Select image', + disabled: false, + isLoading: false, + }, + render: args => ` + + ${args.content} `, }; diff --git a/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.tsx b/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.tsx index 5300a0e9..5518de7f 100644 --- a/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.tsx +++ b/lib/ui/src/components/molecules/image-uploader-btn/image-uploader-btn.tsx @@ -4,12 +4,14 @@ import { Event, EventEmitter, Host, + Prop, State, h, } from '@stencil/core'; export interface ImageMetadata { url: string; + bytes: Uint8Array; size: number; width: number; height: number; @@ -21,6 +23,7 @@ export interface ImageSet { lg: ImageMetadata; xl: ImageMetadata; xxl: ImageMetadata; + type: string; } const SM_IMAGE_WIDTH = 640; @@ -38,6 +41,12 @@ const IMAGE_QUALITY = 0.8; scoped: true, }) export class ImageUploaderBtnComponent implements ComponentInterface { + @Prop({ reflect: true }) + public isLoading?: boolean; + + @Prop({ reflect: true }) + public disabled?: boolean; + @Event() public imagesSelected!: EventEmitter; @@ -50,16 +59,20 @@ export class ImageUploaderBtnComponent implements ComponentInterface { this.setFileInputElem(elem)} onChange={() => this.onFileInputChanged()} /> - this.onSelectImagesBtnClicked()}> + this.onSelectImagesBtnClicked()} + > - + ); } @@ -113,8 +126,8 @@ export class ImageUploaderBtnComponent implements ComponentInterface { } } -async function getImageSetFromFile(blob: Blob): Promise { - const dataUrl = URL.createObjectURL(blob); +async function getImageSetFromFile(file: File): Promise { + const dataUrl = URL.createObjectURL(file); const [sm, md, lg, xl, xxl] = await Promise.all([ await getImageMetadata(dataUrl, SM_IMAGE_WIDTH), @@ -132,6 +145,7 @@ async function getImageSetFromFile(blob: Blob): Promise { lg, xl, xxl, + type: file.type, }; } @@ -145,6 +159,7 @@ async function getImageMetadata( return { url: resizedDataUrl, + bytes: new Uint8Array(await resizedBlob.arrayBuffer()), size: resizedBlob.size, width: resizedImageElem.width, height: resizedImageElem.height, diff --git a/lib/ui/src/components/molecules/loading-btn/loading-btn.scss b/lib/ui/src/components/molecules/loading-btn/loading-btn.scss new file mode 100644 index 00000000..ef46d1ce --- /dev/null +++ b/lib/ui/src/components/molecules/loading-btn/loading-btn.scss @@ -0,0 +1,10 @@ +.loading-btn__icon { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.loading-btn__text--transparent { + color: transparent; +} diff --git a/lib/ui/src/components/molecules/loading-btn/loading-btn.stories.ts b/lib/ui/src/components/molecules/loading-btn/loading-btn.stories.ts new file mode 100644 index 00000000..52bfd12d --- /dev/null +++ b/lib/ui/src/components/molecules/loading-btn/loading-btn.stories.ts @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from '@storybook/html'; + +const meta: Meta = { + title: 'Molecules/Loading Button', + argTypes: { + content: { + name: 'Content', + control: { type: 'text' }, + }, + disabled: { + name: 'Disabled', + control: { type: 'boolean' }, + }, + isLoading: { + name: 'Loading', + control: { type: 'boolean' }, + }, + }, + args: { + content: 'Save', + disabled: false, + isLoading: true, + }, + render: args => ` + + ${args.content} + + `, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/lib/ui/src/components/molecules/loading-btn/loading-btn.tsx b/lib/ui/src/components/molecules/loading-btn/loading-btn.tsx new file mode 100644 index 00000000..e99023ee --- /dev/null +++ b/lib/ui/src/components/molecules/loading-btn/loading-btn.tsx @@ -0,0 +1,49 @@ +import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; +import { ButtonType, Theme } from '../../../types'; + +@Component({ + tag: 'cg-loading-btn', + styleUrl: 'loading-btn.scss', + scoped: true, +}) +export class LoadingBtnComponent implements ComponentInterface { + @Prop({ reflect: true }) + public type: ButtonType = 'button'; + + @Prop({ reflect: true }) + public theme?: Theme; + + @Prop({ reflect: true }) + public isLoading?: boolean; + + @Prop({ reflect: true }) + public disabled?: boolean; + + public render() { + return ( + + + {this.isLoading && ( + + )} + +
+ +
+
+
+ ); + } +} diff --git a/lib/ui/src/components/organisms/footer/footer.tsx b/lib/ui/src/components/organisms/footer/footer.tsx index 40e5ec94..79cc7454 100644 --- a/lib/ui/src/components/organisms/footer/footer.tsx +++ b/lib/ui/src/components/organisms/footer/footer.tsx @@ -1,4 +1,4 @@ -import { Component, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Prop, h } from '@stencil/core'; import { NavLinkCategory } from '../../../types'; @Component({ @@ -6,7 +6,7 @@ import { NavLinkCategory } from '../../../types'; styleUrl: 'footer.scss', scoped: true, }) -export class FooterComponent { +export class FooterComponent implements ComponentInterface { @Prop({ reflect: true }) public links!: NavLinkCategory[]; diff --git a/lib/ui/src/components/organisms/navbar/navbar.tsx b/lib/ui/src/components/organisms/navbar/navbar.tsx index b69c6be9..01dc7175 100644 --- a/lib/ui/src/components/organisms/navbar/navbar.tsx +++ b/lib/ui/src/components/organisms/navbar/navbar.tsx @@ -1,4 +1,4 @@ -import { Component, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Prop, h } from '@stencil/core'; import { NavLink, NavLinkCategory, isLinkCategory } from '../../../types'; @Component({ @@ -6,7 +6,7 @@ import { NavLink, NavLinkCategory, isLinkCategory } from '../../../types'; styleUrl: 'navbar.scss', scoped: true, }) -export class Navbar { +export class Navbar implements ComponentInterface { @Prop({ reflect: true }) public homeUrl = '/'; diff --git a/src/frontend/src/.ic-assets.json b/src/frontend/src/.ic-assets.json index 28206195..2f47a488 100644 --- a/src/frontend/src/.ic-assets.json +++ b/src/frontend/src/.ic-assets.json @@ -2,7 +2,7 @@ { "match": "**/*", "headers": { - "Content-Security-Policy": "default-src 'self'; connect-src 'self' https://icp-api.io; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content", + "Content-Security-Policy": "default-src 'self'; connect-src 'self' https://icp-api.io; img-src 'self' https://icp0.io http://localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content", "Permissions-Policy": "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), speaker-selection=(), conversion-measurement=(), focus-without-user-activation=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), sync-script=(), trust-token-redemption=(), window-placement=(), vertical-scroll=()", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin", diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts index 508879ed..0619052d 100644 --- a/src/frontend/src/app/app.component.ts +++ b/src/frontend/src/app/app.component.ts @@ -17,27 +17,25 @@ import { ProfileService } from '~core/state'; FooterComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .app-container { - display: flex; - flex-direction: column; - min-height: 100vh; - } + .app-container { + display: flex; + flex-direction: column; + min-height: 100vh; + } - .content-container { - margin-left: auto; - margin-right: auto; - flex: 1; - @include common.px(3); - padding-top: common.size(6); - padding-bottom: common.size(10); - @include common.container; - } - `, - ], + .content-container { + margin-left: auto; + margin-right: auto; + flex: 1; + @include common.px(3); + padding-top: common.size(6); + padding-bottom: common.size(10); + @include common.container; + } + `, template: `
diff --git a/src/frontend/src/app/core/api/review/review-api.mapper.ts b/src/frontend/src/app/core/api/review/review-api.mapper.ts index 69a6d05f..33deea56 100644 --- a/src/frontend/src/app/core/api/review/review-api.mapper.ts +++ b/src/frontend/src/app/core/api/review/review-api.mapper.ts @@ -6,9 +6,7 @@ import { toCandidOpt, } from '../../utils'; import { mapGetProposalReviewCommitResponse } from '../commit-review/commit-review-api.mapper'; -import { ImageSet } from '@cg/angular-ui'; import { - ProposalReviewWithId, CreateProposalReviewRequest as CreateProposalReviewApiRequest, UpdateProposalReviewRequest as UpdateProposalReviewApiRequest, ListProposalReviewsRequest as ListProposalReviewsApiRequest, @@ -18,7 +16,12 @@ import { ProposalVote as ApiProposalVote, GetMyProposalReviewSummaryRequest as GetMyProposalReviewSummaryApiRequest, GetMyProposalReviewSummaryResponse as GetMyProposalReviewSummaryApiResponse, + GetProposalReviewResponse as GetProposalReviewApiResponse, + CreateProposalReviewImageRequest as CreateProposalReviewImageApiRequest, + CreateProposalReviewImageResponse as CreateProposalReviewImageApiResponse, + DeleteProposalReviewImageRequest as DeleteProposalReviewImageApiRequest, } from '@cg/backend'; +import { ENV } from '~env'; import { GetProposalReviewResponse, UpdateProposalReviewRequest, @@ -28,6 +31,9 @@ import { GetProposalReviewRequest, ProposalReviewStatus, GetMyProposalReviewSummaryResponse, + CreateProposalReviewImageRequest, + CreateProposalReviewImageResponse, + DeleteProposalReviewImageRequest, } from './review-api.model'; export function mapCreateProposalReviewRequest( @@ -79,7 +85,7 @@ export function mapGetMyProposalReviewRequest( } export function mapGetProposalReviewResponse( - res: ProposalReviewWithId, + res: Ok, ): GetProposalReviewResponse { const review = res.proposal_review; @@ -93,9 +99,11 @@ export function mapGetProposalReviewResponse( status: mapProposalReviewStatusResponse(review.status), summary: fromCandidOpt(review.summary), buildReproduced: fromCandidOpt(review.build_reproduced), - // [TODO] - connect with API once it's implemented - reproducedBuildImageId: getReviewImages(), - commits: res.proposal_review.proposal_review_commits.map( + images: review.images_paths.map(path => ({ + // [TODO]: use current domain when canisters are merged + path: `${ENV.BACKEND_ORIGIN}${path}`, + })), + commits: review.proposal_review_commits.map( mapGetProposalReviewCommitResponse, ), }; @@ -117,6 +125,34 @@ export function mapGetMyProposalReviewSummaryResponse( }; } +export function mapCreateProposalReviewImageRequest( + req: CreateProposalReviewImageRequest, +): CreateProposalReviewImageApiRequest { + return { + proposal_id: req.proposalId, + content_type: req.contentType, + content_bytes: req.contentBytes, + }; +} + +export function mapCreateProposalReviewImageResponse( + res: Ok, +): CreateProposalReviewImageResponse { + return { + // [TODO]: use current domain when canisters are merged + path: `${ENV.BACKEND_ORIGIN}${res.path}`, + }; +} + +export function mapDeleteProposalReviewImageRequest( + req: DeleteProposalReviewImageRequest, +): DeleteProposalReviewImageApiRequest { + return { + proposal_id: req.proposalId, + image_path: req.imagePath, + }; +} + function mapProposalReviewStatusRequest( status?: ProposalReviewStatus | null, ): ProposalReviewStatusApi | null { @@ -170,72 +206,3 @@ function mapProposalVoteResponse(vote: ApiProposalVote): boolean | null { return null; } - -function getReviewImages(): ImageSet[] { - return [ - { - sm: { - url: '../assets/apple-touch-icon.png', - size: 10, - width: 10, - height: 10, - }, - md: { - url: '../assets/apple-touch-icon.png', - size: 100, - width: 100, - height: 100, - }, - lg: { - url: '../assets/apple-touch-icon.png', - size: 100, - width: 100, - height: 100, - }, - xl: { - url: '../assets/apple-touch-icon.png', - size: 100, - width: 100, - height: 100, - }, - xxl: { - url: '../assets/apple-touch-icon.png', - size: 100, - width: 100, - height: 100, - }, - }, - { - sm: { - url: '../assets/codegov-logo.png', - size: 10, - width: 10, - height: 10, - }, - md: { - url: '../assets/codegov-logo.png', - size: 100, - width: 100, - height: 100, - }, - lg: { - url: '../assets/codegov-logo.png', - size: 100, - width: 100, - height: 100, - }, - xl: { - url: '../assets/codegov-logo.png', - size: 100, - width: 100, - height: 100, - }, - xxl: { - url: '../assets/codegov-logo.png', - size: 100, - width: 100, - height: 100, - }, - }, - ]; -} diff --git a/src/frontend/src/app/core/api/review/review-api.model.ts b/src/frontend/src/app/core/api/review/review-api.model.ts index 6b0b9a26..6f337233 100644 --- a/src/frontend/src/app/core/api/review/review-api.model.ts +++ b/src/frontend/src/app/core/api/review/review-api.model.ts @@ -1,5 +1,4 @@ import { GetProposalReviewCommitResponse } from '../commit-review'; -import { ImageSet } from '@cg/angular-ui'; export interface CreateProposalReviewRequest { proposalId: string; @@ -39,10 +38,14 @@ export interface GetProposalReviewResponse { status: ProposalReviewStatus; summary: string | null; buildReproduced: boolean | null; - reproducedBuildImageId: ImageSet[]; + images: ProposalReviewImage[]; commits: GetProposalReviewCommitResponse[]; } +export interface ProposalReviewImage { + path: string; +} + export interface GetMyProposalReviewSummaryRequest { proposalId: string; } @@ -51,6 +54,21 @@ export interface GetMyProposalReviewSummaryResponse { summaryMarkdown: string; } +export interface CreateProposalReviewImageRequest { + proposalId: string; + contentType: string; + contentBytes: Uint8Array; +} + +export interface CreateProposalReviewImageResponse { + path: string; +} + +export interface DeleteProposalReviewImageRequest { + proposalId: string; + imagePath: string; +} + export enum ProposalReviewStatus { Draft = 'Draft', Published = 'Published', diff --git a/src/frontend/src/app/core/api/review/review-api.service.spec.ts b/src/frontend/src/app/core/api/review/review-api.service.spec.ts index e3d78596..85070ab2 100644 --- a/src/frontend/src/app/core/api/review/review-api.service.spec.ts +++ b/src/frontend/src/app/core/api/review/review-api.service.spec.ts @@ -1,6 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { ImageSet } from '@cg/angular-ui'; import { CreateProposalReviewRequest as CreateProposalReviewApiRequest, CreateProposalReviewResponse as CreateProposalReviewApiResponse, @@ -93,8 +92,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }; @@ -235,8 +233,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }, { @@ -249,8 +246,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }, ]; @@ -318,8 +314,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }; @@ -388,8 +383,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }; @@ -464,8 +458,7 @@ describe('ReviewApiService', () => { status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - // [TODO] - remove when API is implemented - reproducedBuildImageId: jasmine.any(Array) as unknown as ImageSet[], + images: [], commits: [], }; diff --git a/src/frontend/src/app/core/api/review/review-api.service.ts b/src/frontend/src/app/core/api/review/review-api.service.ts index e8205aef..143b9eea 100644 --- a/src/frontend/src/app/core/api/review/review-api.service.ts +++ b/src/frontend/src/app/core/api/review/review-api.service.ts @@ -3,7 +3,10 @@ import { Injectable, inject } from '@angular/core'; import { BackendActorService } from '../../services'; import { ApiError, handleErr } from '../../utils'; import { + mapCreateProposalReviewImageRequest, + mapCreateProposalReviewImageResponse, mapCreateProposalReviewRequest, + mapDeleteProposalReviewImageRequest, mapGetMyProposalReviewRequest, mapGetMyProposalReviewSummaryRequest, mapGetMyProposalReviewSummaryResponse, @@ -13,7 +16,10 @@ import { mapUpdateProposalReviewRequest, } from './review-api.mapper'; import { + CreateProposalReviewImageRequest, + CreateProposalReviewImageResponse, CreateProposalReviewRequest, + DeleteProposalReviewImageRequest, GetMyProposalReviewRequest, GetMyProposalReviewSummaryRequest, GetMyProposalReviewSummaryResponse, @@ -108,4 +114,26 @@ export class ReviewApiService { throw error; } } + + public async createProposalReviewImage( + req: CreateProposalReviewImageRequest, + ): Promise { + const apiReq = mapCreateProposalReviewImageRequest(req); + + const res = await this.actorService.create_proposal_review_image(apiReq); + const okRes = handleErr(res); + + return mapCreateProposalReviewImageResponse(okRes); + } + + public async deleteProposalReviewImage( + req: DeleteProposalReviewImageRequest, + ): Promise { + const apiReq = mapDeleteProposalReviewImageRequest(req); + + const res = await this.actorService.delete_proposal_review_image(apiReq); + const okRes = handleErr(res); + + return okRes; + } } diff --git a/src/frontend/src/app/core/icons/edit-icon/edit-icon.component.ts b/src/frontend/src/app/core/icons/edit-icon/edit-icon.component.ts index 1ab493b8..e3a4fe92 100644 --- a/src/frontend/src/app/core/icons/edit-icon/edit-icon.component.ts +++ b/src/frontend/src/app/core/icons/edit-icon/edit-icon.component.ts @@ -3,16 +3,14 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-edit-icon', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .edit-icon { - width: common.size(6); - height: common.size(6); - } - `, - ], + .edit-icon { + width: common.size(6); + height: common.size(6); + } + `, template: ` - - - - - `, -}) -export class LoadingIconComponent { - public readonly theme = input<'white' | 'primary'>('primary'); -} diff --git a/src/frontend/src/app/core/icons/login-icon/login-icon.component.ts b/src/frontend/src/app/core/icons/login-icon/login-icon.component.ts index c7c5e223..0291f592 100644 --- a/src/frontend/src/app/core/icons/login-icon/login-icon.component.ts +++ b/src/frontend/src/app/core/icons/login-icon/login-icon.component.ts @@ -3,16 +3,14 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-login-icon', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .login-icon { - width: common.size(6); - height: common.size(6); - } - `, - ], + .login-icon { + width: common.size(6); + height: common.size(6); + } + `, template: `
diff --git a/src/frontend/src/app/core/state/review-submission/review-submission.service.spec.ts b/src/frontend/src/app/core/state/review-submission/review-submission.service.spec.ts index 56ea5474..245f5d02 100644 --- a/src/frontend/src/app/core/state/review-submission/review-submission.service.spec.ts +++ b/src/frontend/src/app/core/state/review-submission/review-submission.service.spec.ts @@ -379,7 +379,7 @@ function createProposalReviewResponse( status: ProposalReviewStatus.Draft, summary: null, buildReproduced: null, - reproducedBuildImageId: [], + images: [], commits: [], }; } diff --git a/src/frontend/src/app/core/state/review-submission/review-submission.service.ts b/src/frontend/src/app/core/state/review-submission/review-submission.service.ts index e575c081..da8dd01c 100644 --- a/src/frontend/src/app/core/state/review-submission/review-submission.service.ts +++ b/src/frontend/src/app/core/state/review-submission/review-submission.service.ts @@ -16,6 +16,7 @@ import { UpdateProposalReviewRequest, ReviewCommitDetails, ProposalReviewStatus, + CreateProposalReviewImageResponse, } from '../../api'; import { batchApiCall, filterNotNil, isNil, isNotNil } from '../../utils'; @@ -323,6 +324,36 @@ export class ReviewSubmissionService { return [commitSubject, subscription]; } + public async createProposalReviewImage( + type: string, + bytes: Uint8Array, + ): Promise { + if (isNil(this.proposalId)) { + throw new Error( + 'Tried to create a proposal image without selecting a proposal', + ); + } + + return await this.reviewApiService.createProposalReviewImage({ + contentType: type, + contentBytes: bytes, + proposalId: this.proposalId, + }); + } + + public async deleteProposalReviewImage(imagePath: string): Promise { + if (isNil(this.proposalId)) { + throw new Error( + 'Tried to delete a proposal image without selecting a proposal', + ); + } + + await this.reviewApiService.deleteProposalReviewImage({ + imagePath, + proposalId: this.proposalId, + }); + } + private emitUpdatedComits(): void { this.commitsSubject.next(Array.from(this.commits.values())); } diff --git a/src/frontend/src/app/core/ui/form-field/form-field.component.ts b/src/frontend/src/app/core/ui/form-field/form-field.component.ts index 3dcd0130..8129170f 100644 --- a/src/frontend/src/app/core/ui/form-field/form-field.component.ts +++ b/src/frontend/src/app/core/ui/form-field/form-field.component.ts @@ -5,6 +5,7 @@ import { DestroyRef, Optional, SkipSelf, + TemplateRef, computed, contentChild, contentChildren, @@ -23,24 +24,22 @@ import { InputHintComponent } from '../input-hint'; selector: 'app-form-field', imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; - - :host { - display: flex; - flex-direction: column; - width: 100%; - } + styles: ` + @use '@cg/styles/common'; - .form-field__feedback { - margin-left: common.size(1); - height: common.size(4); - padding-top: common.size(1); - @include common.text-xs; - } - `, - ], + :host { + display: flex; + flex-direction: column; + width: 100%; + } + + .form-field__feedback { + margin-left: common.size(1); + height: common.size(4); + padding-top: common.size(1); + @include common.text-xs; + } + `, template: ` @@ -87,15 +86,7 @@ export class FormFieldComponent { descendants: true, }); public readonly hasError = signal(false); - public readonly errorTemplate = computed(() => { - for (const inputError of this.inputErrorComponents()) { - if (this.formControl().errors?.[inputError.key()]) { - return inputError.getTemplateRef(); - } - } - - return null; - }); + public readonly errorTemplate = signal | null>(null); constructor( @SkipSelf() @@ -108,12 +99,14 @@ export class FormFieldComponent { .statusChanges.pipe(takeUntilDestroyed(onDestroy)) .subscribe(() => { this.setHasError(); + this.setErrorTemplate(); }); }); effect(() => { this.inputDirective().touchChange.subscribe(() => { this.setHasError(); + this.setErrorTemplate(); }); }); } @@ -121,4 +114,15 @@ export class FormFieldComponent { private setHasError(): void { this.hasError.set(formHasError(this.formControl())); } + + private setErrorTemplate(): void { + const formControl = this.formControl(); + const inputErrorComponents = this.inputErrorComponents(); + + for (const inputError of inputErrorComponents) { + if (formControl.errors?.[inputError.key()]) { + this.errorTemplate.set(inputError.getTemplateRef()); + } + } + } } diff --git a/src/frontend/src/app/core/ui/form-validation-info/form-validation-info.component.ts b/src/frontend/src/app/core/ui/form-validation-info/form-validation-info.component.ts index 347c038b..fa425b77 100644 --- a/src/frontend/src/app/core/ui/form-validation-info/form-validation-info.component.ts +++ b/src/frontend/src/app/core/ui/form-validation-info/form-validation-info.component.ts @@ -13,24 +13,22 @@ import { isNotNil } from '../../utils'; @Component({ selector: 'app-form-validation-info', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; - - :host { - display: block; - text-align: right; - margin-bottom: common.size(2); - - color: common.$error; - @include common.text-sm; - } - - :host-context(.form-validation-info--hidden) { - color: transparent; - } - `, - ], + styles: ` + @use '@cg/styles/common'; + + :host { + display: block; + text-align: right; + margin-top: common.size(4); + + color: common.$error; + @include common.text-sm; + } + + :host-context(.form-validation-info--hidden) { + color: transparent; + } + `, template: ` There are some errors in your form. Please fix them and try again. `, diff --git a/src/frontend/src/app/core/ui/index.ts b/src/frontend/src/app/core/ui/index.ts index 84de7f2c..45e36152 100644 --- a/src/frontend/src/app/core/ui/index.ts +++ b/src/frontend/src/app/core/ui/index.ts @@ -6,7 +6,6 @@ export * from './input-hint'; export * from './key-col'; export * from './key-value-grid'; export * from './label'; -export * from './loading-button'; export * from './loading-dialog'; export * from './tooltip'; export * from './value-col'; diff --git a/src/frontend/src/app/core/ui/input-error/input-error.component.ts b/src/frontend/src/app/core/ui/input-error/input-error.component.ts index e3eb2624..0773941d 100644 --- a/src/frontend/src/app/core/ui/input-error/input-error.component.ts +++ b/src/frontend/src/app/core/ui/input-error/input-error.component.ts @@ -9,15 +9,13 @@ import { @Component({ selector: 'app-input-error', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .input-error { - color: common.$error; - } - `, - ], + .input-error { + color: common.$error; + } + `, template: `
diff --git a/src/frontend/src/app/core/ui/key-col/key-col.component.ts b/src/frontend/src/app/core/ui/key-col/key-col.component.ts index f49cded8..17bdbc5b 100644 --- a/src/frontend/src/app/core/ui/key-col/key-col.component.ts +++ b/src/frontend/src/app/core/ui/key-col/key-col.component.ts @@ -3,24 +3,23 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-key-col', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - grid-column: span 3; - color: common.$black; + :host { + grid-column: span 3; + align-content: center; + color: common.$black; - @include common.md { - grid-column: span 1; - } + @include common.md { + grid-column: span 1; + } - @include common.dark { - color: common.$white; - } + @include common.dark { + color: common.$white; } - `, - ], + } + `, template: ``, }) export class KeyColComponent {} diff --git a/src/frontend/src/app/core/ui/key-value-grid/key-value-grid.component.ts b/src/frontend/src/app/core/ui/key-value-grid/key-value-grid.component.ts index 1b56335d..a857436b 100644 --- a/src/frontend/src/app/core/ui/key-value-grid/key-value-grid.component.ts +++ b/src/frontend/src/app/core/ui/key-value-grid/key-value-grid.component.ts @@ -9,29 +9,27 @@ import { @Component({ selector: 'app-key-value-grid', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - display: grid; - grid-template-columns: repeat(3, 1fr); + :host { + display: grid; + grid-template-columns: repeat(3, 1fr); - gap: common.size(1) common.size(8); - @include common.md { - gap: common.size(6) common.size(8); - } + gap: common.size(1) common.size(8); + @include common.md { + gap: common.size(6) common.size(8); } + } - :host-context(.key-value-grid--1-col) { - grid-template-columns: repeat(3, 1fr); - } + :host-context(.key-value-grid--1-col) { + grid-template-columns: repeat(3, 1fr); + } - :host-context(.key-value-grid--2-col) { - grid-template-columns: repeat(6, 1fr); - } - `, - ], + :host-context(.key-value-grid--2-col) { + grid-template-columns: repeat(6, 1fr); + } + `, template: ``, }) export class KeyValueGridComponent { diff --git a/src/frontend/src/app/core/ui/loading-button/index.ts b/src/frontend/src/app/core/ui/loading-button/index.ts deleted file mode 100644 index 840ff264..00000000 --- a/src/frontend/src/app/core/ui/loading-button/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './loading-button.component'; diff --git a/src/frontend/src/app/core/ui/loading-button/loading-button.component.ts b/src/frontend/src/app/core/ui/loading-button/loading-button.component.ts deleted file mode 100644 index fdd434b5..00000000 --- a/src/frontend/src/app/core/ui/loading-button/loading-button.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; - -import { LoadingIconComponent } from '../../icons'; - -@Component({ - selector: 'app-loading-button', - imports: [LoadingIconComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - .loading-button { - &:disabled { - cursor: not-allowed; - } - } - - .loading-button__text--transparent { - color: transparent; - } - `, - ], - template: ` - - `, -}) -export class LoadingButtonComponent { - public readonly type = input<'submit' | 'button'>('button'); - - public readonly theme = input<'primary' | 'white'>('primary'); - - public readonly disabled = input(false); - - public readonly btnClass = input(''); - - public readonly isSaving = input(false); -} diff --git a/src/frontend/src/app/core/ui/loading-dialog/loading-dialog.component.ts b/src/frontend/src/app/core/ui/loading-dialog/loading-dialog.component.ts index 82c4221f..be48d435 100644 --- a/src/frontend/src/app/core/ui/loading-dialog/loading-dialog.component.ts +++ b/src/frontend/src/app/core/ui/loading-dialog/loading-dialog.component.ts @@ -1,50 +1,48 @@ import { DialogRef } from '@angular/cdk/dialog'; import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; -import { LoadingIconComponent } from '~core/icons'; +import { LoadingIconComponent } from '@cg/angular-ui'; @Component({ selector: 'app-loading-content', imports: [LoadingIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.layer-50; + :host { + @include common.layer-50; - display: flex; - flex-direction: row; - align-items: center; + display: flex; + flex-direction: row; + align-items: center; - @include common.px(4); - @include common.py(4); - width: auto; - border-radius: common.$border-md-radius; + @include common.px(4); + @include common.py(4); + width: auto; + border-radius: common.$border-md-radius; - color: common.$white; - background-color: common.$primary-800; + color: common.$white; + background-color: common.$primary-800; - @include common.dark { - background-color: common.$slate-700; - color: common.$slate-200; - } + @include common.dark { + background-color: common.$slate-700; + color: common.$slate-200; } + } - .dialog__icon { - width: common.size(11); - height: common.size(11); - } + .dialog__icon { + width: common.size(11); + height: common.size(11); + } - .dialog__text { - @include common.mx(4); - margin-bottom: 0; - } - `, - ], + .dialog__text { + @include common.mx(4); + margin-bottom: 0; + } + `, template: ` - +

{{ infoText() }}

`, diff --git a/src/frontend/src/app/core/ui/tooltip/tooltip.component.ts b/src/frontend/src/app/core/ui/tooltip/tooltip.component.ts index 4b00d3df..cad9ce93 100644 --- a/src/frontend/src/app/core/ui/tooltip/tooltip.component.ts +++ b/src/frontend/src/app/core/ui/tooltip/tooltip.component.ts @@ -3,28 +3,26 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; @Component({ selector: 'app-tooltip', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - background-color: common.$primary-900; - border-color: 0.5px solid common.$primary-600; - width: auto; - border-radius: common.$border-md-radius; - @include common.px(2); - @include common.py(1); + :host { + background-color: common.$primary-900; + border-color: 0.5px solid common.$primary-600; + width: auto; + border-radius: common.$border-md-radius; + @include common.px(2); + @include common.py(1); - @include common.text-xs; - color: common.$white; + @include common.text-xs; + color: common.$white; - @include common.dark { - background-color: common.$slate-900; - border-color: common.$slate-500; - } + @include common.dark { + background-color: common.$slate-900; + border-color: common.$slate-500; } - `, - ], + } + `, template: `{{ tooltipText() }}`, }) export class TooltipComponent { diff --git a/src/frontend/src/app/core/ui/value-col/value-col.component.ts b/src/frontend/src/app/core/ui/value-col/value-col.component.ts index d7aff48b..bfaab735 100644 --- a/src/frontend/src/app/core/ui/value-col/value-col.component.ts +++ b/src/frontend/src/app/core/ui/value-col/value-col.component.ts @@ -3,24 +3,20 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-value-col', changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - display: flex; - flex-direction: row; - align-items: center; + :host { + grid-column: span 3; + align-content: center; + margin-bottom: common.size(6); - grid-column: span 3; - margin-bottom: common.size(6); - @include common.md { - grid-column: span 2; - margin-bottom: 0; - } + @include common.md { + grid-column: span 2; + margin-bottom: 0; } - `, - ], + } + `, template: ``, }) export class ValueColComponent {} diff --git a/src/frontend/src/app/pages/profile-edit/admin-personal-info-form/admin-personal-info-form.component.ts b/src/frontend/src/app/pages/profile-edit/admin-personal-info-form/admin-personal-info-form.component.ts index 949caba9..f2ad1478 100644 --- a/src/frontend/src/app/pages/profile-edit/admin-personal-info-form/admin-personal-info-form.component.ts +++ b/src/frontend/src/app/pages/profile-edit/admin-personal-info-form/admin-personal-info-form.component.ts @@ -14,6 +14,7 @@ import { Validators, } from '@angular/forms'; +import { LoadingBtnComponent, TextBtnComponent } from '@cg/angular-ui'; import { AdminUserProfile, AdminUpdateMyUserProfileRequest, @@ -28,7 +29,6 @@ import { KeyColComponent, KeyValueGridComponent, LabelDirective, - LoadingButtonComponent, ValueColComponent, } from '~core/ui'; @@ -50,7 +50,8 @@ interface AdminProfileForm { KeyValueGridComponent, KeyColComponent, ValueColComponent, - LoadingButtonComponent, + LoadingBtnComponent, + TextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -99,22 +100,17 @@ interface AdminProfileForm {
- + - Save - +
`, diff --git a/src/frontend/src/app/pages/profile-edit/admin-personal-info/admin-personal-info.component.ts b/src/frontend/src/app/pages/profile-edit/admin-personal-info/admin-personal-info.component.ts index 0655d211..ea4b7f7e 100644 --- a/src/frontend/src/app/pages/profile-edit/admin-personal-info/admin-personal-info.component.ts +++ b/src/frontend/src/app/pages/profile-edit/admin-personal-info/admin-personal-info.component.ts @@ -5,6 +5,7 @@ import { output, } from '@angular/core'; +import { TextBtnComponent } from '@cg/angular-ui'; import { AdminUserProfile } from '~core/api'; import { KeyColComponent, @@ -14,7 +15,12 @@ import { @Component({ selector: 'app-admin-personal-info', - imports: [KeyValueGridComponent, KeyColComponent, ValueColComponent], + imports: [ + KeyValueGridComponent, + KeyColComponent, + ValueColComponent, + TextBtnComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -30,7 +36,7 @@ import {
- + Edit
`, }) diff --git a/src/frontend/src/app/pages/profile-edit/admin-profile/admin-profile.component.ts b/src/frontend/src/app/pages/profile-edit/admin-profile/admin-profile.component.ts index 5e1442b2..de1703d4 100644 --- a/src/frontend/src/app/pages/profile-edit/admin-profile/admin-profile.component.ts +++ b/src/frontend/src/app/pages/profile-edit/admin-profile/admin-profile.component.ts @@ -32,19 +32,17 @@ import { AdminPersonalInfoComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .admin-profile-card { - margin-bottom: common.size(3); + .admin-profile-card { + margin-bottom: common.size(3); - @include common.sm { - margin-bottom: common.size(4); - } + @include common.sm { + margin-bottom: common.size(4); } - `, - ], + } + `, template: `

Profile

diff --git a/src/frontend/src/app/pages/profile-edit/profile-edit.component.ts b/src/frontend/src/app/pages/profile-edit/profile-edit.component.ts index f010e9c2..44f7ba0a 100644 --- a/src/frontend/src/app/pages/profile-edit/profile-edit.component.ts +++ b/src/frontend/src/app/pages/profile-edit/profile-edit.component.ts @@ -21,15 +21,13 @@ import { ReviewerProfileComponent } from './reviewer-profile'; AdminProfileComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.page-content; - } - `, - ], + :host { + @include common.page-content; + } + `, template: `

Edit profile

diff --git a/src/frontend/src/app/pages/profile-edit/reviewer-personal-info-form/reviewer-personal-info-form.component.ts b/src/frontend/src/app/pages/profile-edit/reviewer-personal-info-form/reviewer-personal-info-form.component.ts index c4fd8710..782ecfe4 100644 --- a/src/frontend/src/app/pages/profile-edit/reviewer-personal-info-form/reviewer-personal-info-form.component.ts +++ b/src/frontend/src/app/pages/profile-edit/reviewer-personal-info-form/reviewer-personal-info-form.component.ts @@ -15,6 +15,7 @@ import { Validators, } from '@angular/forms'; +import { LoadingBtnComponent, TextBtnComponent } from '@cg/angular-ui'; import { ReviewerUserProfile, UpdateMyUserProfileRequest, @@ -30,7 +31,6 @@ import { KeyColComponent, KeyValueGridComponent, LabelDirective, - LoadingButtonComponent, ValueColComponent, } from '~core/ui'; @@ -54,7 +54,8 @@ export interface ReviewerProfileForm { KeyValueGridComponent, KeyColComponent, ValueColComponent, - LoadingButtonComponent, + LoadingBtnComponent, + TextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -134,22 +135,17 @@ export interface ReviewerProfileForm {
- + - Save - +
`, diff --git a/src/frontend/src/app/pages/profile-edit/reviewer-personal-info/reviewer-personal-info.component.ts b/src/frontend/src/app/pages/profile-edit/reviewer-personal-info/reviewer-personal-info.component.ts index 228bce9b..0ce2d61a 100644 --- a/src/frontend/src/app/pages/profile-edit/reviewer-personal-info/reviewer-personal-info.component.ts +++ b/src/frontend/src/app/pages/profile-edit/reviewer-personal-info/reviewer-personal-info.component.ts @@ -5,6 +5,7 @@ import { output, } from '@angular/core'; +import { TextBtnComponent } from '@cg/angular-ui'; import { ReviewerUserProfile } from '~core/api'; import { KeyColComponent, @@ -14,7 +15,12 @@ import { @Component({ selector: 'app-reviewer-personal-info', - imports: [KeyValueGridComponent, KeyColComponent, ValueColComponent], + imports: [ + KeyValueGridComponent, + KeyColComponent, + ValueColComponent, + TextBtnComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -45,7 +51,7 @@ import {
- + Edit
`, }) diff --git a/src/frontend/src/app/pages/profile-edit/reviewer-profile/reviewer-profile.component.ts b/src/frontend/src/app/pages/profile-edit/reviewer-profile/reviewer-profile.component.ts index a6cdcc1c..0c74475a 100644 --- a/src/frontend/src/app/pages/profile-edit/reviewer-profile/reviewer-profile.component.ts +++ b/src/frontend/src/app/pages/profile-edit/reviewer-profile/reviewer-profile.component.ts @@ -36,20 +36,18 @@ import { ReviewerSocialMediaFormComponent, ReviewerSocialMediaComponent, ], - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .reviewer-profile-card, - .reviewer-personal-info-card { - margin-bottom: common.size(3); + .reviewer-profile-card, + .reviewer-personal-info-card { + margin-bottom: common.size(3); - @include common.sm { - margin-bottom: common.size(4); - } + @include common.sm { + margin-bottom: common.size(4); } - `, - ], + } + `, template: `

Profile

diff --git a/src/frontend/src/app/pages/profile-edit/reviewer-social-media-form/reviewer-social-media-form.component.ts b/src/frontend/src/app/pages/profile-edit/reviewer-social-media-form/reviewer-social-media-form.component.ts index e373b255..18c64a23 100644 --- a/src/frontend/src/app/pages/profile-edit/reviewer-social-media-form/reviewer-social-media-form.component.ts +++ b/src/frontend/src/app/pages/profile-edit/reviewer-social-media-form/reviewer-social-media-form.component.ts @@ -16,6 +16,7 @@ import { } from '@angular/forms'; import { SOCIAL_MEDIA_INPUTS, SocialMediaInputs } from '../profile.model'; +import { LoadingBtnComponent, TextBtnComponent } from '@cg/angular-ui'; import { ReviewerUserProfile, SocialMediaLink, @@ -32,7 +33,6 @@ import { KeyColComponent, KeyValueGridComponent, LabelDirective, - LoadingButtonComponent, ValueColComponent, } from '~core/ui'; import { keysOf } from '~core/utils'; @@ -54,7 +54,8 @@ export type SocialMediaForm = { KeyValueGridComponent, KeyColComponent, ValueColComponent, - LoadingButtonComponent, + LoadingBtnComponent, + TextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -90,22 +91,17 @@ export type SocialMediaForm = {
- + - Save - +
`, diff --git a/src/frontend/src/app/pages/profile-edit/reviewer-social-media/reviewer-social-media.component.ts b/src/frontend/src/app/pages/profile-edit/reviewer-social-media/reviewer-social-media.component.ts index 667b9046..87985885 100644 --- a/src/frontend/src/app/pages/profile-edit/reviewer-social-media/reviewer-social-media.component.ts +++ b/src/frontend/src/app/pages/profile-edit/reviewer-social-media/reviewer-social-media.component.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import { SOCIAL_MEDIA_INPUTS } from '../profile.model'; +import { TextBtnComponent } from '@cg/angular-ui'; import { ReviewerUserProfile } from '~core/api'; import { KeyColComponent, @@ -18,7 +19,12 @@ import { keysOf } from '~core/utils'; @Component({ selector: 'app-reviewer-social-media', - imports: [KeyValueGridComponent, KeyColComponent, ValueColComponent], + imports: [ + KeyValueGridComponent, + KeyColComponent, + ValueColComponent, + TextBtnComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -41,7 +47,7 @@ import { keysOf } from '~core/utils';
- + Edit
`, }) @@ -87,7 +93,7 @@ export class ReviewerSocialMediaComponent { ), ); - public editForm(): void { + public onEditForm(): void { this.edit.emit(); } } diff --git a/src/frontend/src/app/pages/proposal-details/closed-proposal-summary/closed-proposal-summary.component.ts b/src/frontend/src/app/pages/proposal-details/closed-proposal-summary/closed-proposal-summary.component.ts index ced493f2..78be7897 100644 --- a/src/frontend/src/app/pages/proposal-details/closed-proposal-summary/closed-proposal-summary.component.ts +++ b/src/frontend/src/app/pages/proposal-details/closed-proposal-summary/closed-proposal-summary.component.ts @@ -35,46 +35,46 @@ import { isNil, isNotNil, routeParamSignal, toSyncSignal } from '~core/utils'; RouterLink, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; - - .summary, - .review, - .commit { - margin-bottom: common.size(6); - } + styles: ` + @use '@cg/styles/common'; - .answer--positive { - color: common.$success; - } + .summary, + .review, + .commit { + margin-bottom: common.size(6); + } - .answer--negative { - color: common.$error; - } + .answer--positive { + color: common.$success; + } - .summary__vote { - margin-bottom: common.size(4); - } + .answer--negative { + color: common.$error; + } - .summary__vote-position { - display: flex; - flex-direction: row; - } + .summary__vote { + margin-bottom: common.size(4); + } - .reject-icon { - width: common.size(6); - height: common.size(6); - stroke: common.$error; - } + .summary__vote-position { + display: flex; + flex-direction: row; + } - .adopt-icon { - width: common.size(6); - height: common.size(6); - stroke: common.$success; - } - `, - ], + .reject-icon { + width: common.size(6); + height: common.size(6); + stroke: common.$error; + margin-right: common.size(2); + } + + .adopt-icon { + width: common.size(6); + height: common.size(6); + stroke: common.$success; + margin-right: common.size(2); + } + `, template: ` @let reviews = this.reviews(); @let reviewers = this.reviewers(); diff --git a/src/frontend/src/app/pages/proposal-details/proposal-details.component.ts b/src/frontend/src/app/pages/proposal-details/proposal-details.component.ts index e6e5f9d9..afff90d3 100644 --- a/src/frontend/src/app/pages/proposal-details/proposal-details.component.ts +++ b/src/frontend/src/app/pages/proposal-details/proposal-details.component.ts @@ -12,7 +12,11 @@ import { DomSanitizer } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; import { marked } from 'marked'; -import { CardComponent } from '@cg/angular-ui'; +import { + CardComponent, + LinkTextBtnComponent, + TextBtnComponent, +} from '@cg/angular-ui'; import { GetProposalReviewResponse, ProposalLinkBaseUrl, @@ -41,30 +45,30 @@ import { ClosedProposalSummaryComponent } from './closed-proposal-summary'; FormatDatePipe, ClosedProposalSummaryComponent, RouterLink, + TextBtnComponent, + LinkTextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.page-content; - } + :host { + @include common.page-content; + } - .proposal { - margin-bottom: common.size(6); - } + .proposal { + margin-bottom: common.size(6); + } - .proposal__title, - .proposal__proposer { - word-break: break-word; - } + .proposal__title, + .proposal__proposer { + word-break: break-word; + } - .proposal__link { - margin-right: common.size(4); - } - `, - ], + .proposal__link { + margin-right: common.size(4); + } + `, template: ` @if (currentProposal(); as proposal) {
@@ -160,17 +164,13 @@ import { ClosedProposalSummaryComponent } from './closed-proposal-summary'; proposal.state === ProposalState().InProgress && (isReviewer() || isAdmin()) ) { - + } @if ( isReviewer() && proposal.state === ProposalState().InProgress @@ -178,26 +178,23 @@ import { ClosedProposalSummaryComponent } from './closed-proposal-summary'; @let review = userReview(); @if (review === null) { - Create review - + } @else if (review.status === ProposalReviewStatus().Draft) { - Edit review - + } @else if (review.status === ProposalReviewStatus().Published) { - My review - + } }
diff --git a/src/frontend/src/app/pages/proposal-list/proposal-list.component.ts b/src/frontend/src/app/pages/proposal-list/proposal-list.component.ts index ad044647..2ab2c27a 100644 --- a/src/frontend/src/app/pages/proposal-list/proposal-list.component.ts +++ b/src/frontend/src/app/pages/proposal-list/proposal-list.component.ts @@ -10,7 +10,11 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; -import { CardComponent, RadioInputComponent } from '@cg/angular-ui'; +import { + CardComponent, + LinkTextBtnComponent, + RadioInputComponent, +} from '@cg/angular-ui'; import { ProposalLinkBaseUrl, ProposalReviewStatus } from '~core/api'; import { ProposalState } from '~core/api'; import { FormatDatePipe } from '~core/pipes'; @@ -55,62 +59,61 @@ interface FilterForm { CardComponent, KeyValuePipe, RadioInputComponent, + LinkTextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.page-content; - } + :host { + @include common.page-content; + } - .proposal { - margin-bottom: common.size(6); - } + .proposal { + margin-bottom: common.size(6); + } - .proposal__title, - .proposal__proposer { - word-break: break-word; - } + .proposal__title, + .proposal__proposer { + word-break: break-word; + } - .proposal__link { - margin-right: common.size(4); - } + .proposal__link { + margin-right: common.size(4); + } - .proposal__vote { - font-weight: bold; - } + .proposal__vote { + font-weight: bold; + } - .proposal__vote--adopt { - color: common.$success; - } + .proposal__vote--adopt { + color: common.$success; + } - .proposal__vote--reject { - color: common.$error; - } + .proposal__vote--reject { + color: common.$error; + } - .filter { - display: flex; - flex-direction: column; - margin-bottom: common.size(4); + .filter { + display: flex; + flex-direction: column; + margin-bottom: common.size(4); - @include common.sm { - flex-direction: row; - } + @include common.sm { + flex-direction: row; } + } - .filter__label { - margin-bottom: common.size(2); - margin-right: common.size(2); - width: 100%; + .filter__label { + margin-bottom: common.size(2); + margin-right: common.size(2); + width: 100%; - @include common.sm { - width: 50%; - } + @include common.sm { + width: 50%; } - `, - ], + } + `, template: `

Proposals

@@ -238,36 +241,33 @@ interface FilterForm { proposal.state === proposalState().InProgress && isReviewer() ) { @if (proposal.reviewState === undefined) { - Create review - + } @else if ( proposal.reviewState === ProposalReviewStatus().Draft ) { - Edit review - + } @else if ( proposal.reviewState === ProposalReviewStatus().Published ) { - My review - + } } - + View details - +
diff --git a/src/frontend/src/app/pages/proposal-review-edit/proposal-review-edit.component.ts b/src/frontend/src/app/pages/proposal-review-edit/proposal-review-edit.component.ts index 82e35d8a..add940ff 100644 --- a/src/frontend/src/app/pages/proposal-review-edit/proposal-review-edit.component.ts +++ b/src/frontend/src/app/pages/proposal-review-edit/proposal-review-edit.component.ts @@ -10,11 +10,15 @@ import { import { ReactiveFormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; -import { CardComponent, BadgeComponent } from '@cg/angular-ui'; +import { isNotNil, routeParamSignal, toSyncSignal } from '../../core/utils'; +import { + CardComponent, + BadgeComponent, + LoadingBtnComponent, + LinkTextBtnComponent, +} from '@cg/angular-ui'; import { ProposalReviewStatus, ProposalState } from '~core/api'; import { ProposalService, ReviewSubmissionService } from '~core/state'; -import { LoadingButtonComponent } from '~core/ui'; -import { isNotNil, routeParamSignal, toSyncSignal } from '~core/utils'; import { ReviewCommitsFormComponent } from './review-commits-form'; import { ReviewDetailsFormComponent } from './review-details-form'; @@ -26,29 +30,28 @@ import { ReviewDetailsFormComponent } from './review-details-form'; RouterLink, CardComponent, BadgeComponent, - LoadingButtonComponent, + LinkTextBtnComponent, + LoadingBtnComponent, ReviewCommitsFormComponent, ReviewDetailsFormComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.page-content; - } + :host { + @include common.page-content; + } - .section-heading { - margin-top: common.size(8); - } + .section-heading { + margin-top: common.size(8); + } - .publish-btn-group { - margin-top: common.size(4); - padding-right: common.size(4); - } - `, - ], + .publish-btn-group { + margin-top: common.size(4); + padding-right: common.size(4); + } + `, template: ` @if (currentReview(); as review) { @if (currentProposal(); as proposal) { @@ -78,31 +81,26 @@ import { ReviewDetailsFormComponent } from './review-details-form';
- + View review - + @if (review.status === ProposalReviewStatus.Published) { - Unpublish - + } @else { - Publish - + }
} diff --git a/src/frontend/src/app/pages/proposal-review-edit/review-commits-form/review-commits-form.component.ts b/src/frontend/src/app/pages/proposal-review-edit/review-commits-form/review-commits-form.component.ts index c1371880..51d61a72 100644 --- a/src/frontend/src/app/pages/proposal-review-edit/review-commits-form/review-commits-form.component.ts +++ b/src/frontend/src/app/pages/proposal-review-edit/review-commits-form/review-commits-form.component.ts @@ -19,7 +19,11 @@ import { } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { CardComponent, RadioInputComponent } from '@cg/angular-ui'; +import { + CardComponent, + RadioInputComponent, + TextBtnComponent, +} from '@cg/angular-ui'; import { ReviewCommitDetails } from '~core/api'; import { ReviewSubmissionService } from '~core/state'; import { @@ -53,20 +57,22 @@ import { InputErrorComponent, InputHintComponent, RadioInputComponent, + TextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - .commit-review-card { - margin-bottom: common.size(4); - } - `, - ], + .commit-review-card { + margin-bottom: common.size(4); + } + `, template: ` - @for (commit of commits(); track commit.uiId; let i = $index) { - + @let commitForms = this.commitForms(); + @let commits = this.commits(); + + @for (commit of commits; track commit.uiId; let i = $index) { +
@@ -96,18 +102,18 @@ import { - @if (commitForms()[i].controls.commitSha.value) { + @if (commitForms[i].controls.commitSha.value) { https://github.com/dfinity/ic/commit/{{ - commitForms()[i].controls.commitSha.value + commitForms[i].controls.commitSha.value }} } @else { @@ -151,7 +157,7 @@ import {
Matches description
@@ -206,24 +212,22 @@ import {
- + + Remove commit +
} -
- -
+ + Add commit + `, }) export class ReviewCommitsFormComponent implements OnDestroy { @@ -238,6 +242,10 @@ export class ReviewCommitsFormComponent implements OnDestroy { commitForm.setValue( commitToFormValue(commit.commit.commitSha, commit.commit.details), ); + + this.onCommitReviewedChange(commit.commit.details.reviewed, commitForm); + this.onCommitShaChange(commit.commit.commitSha, commitForm); + return commitForm; }); }); diff --git a/src/frontend/src/app/pages/proposal-review-edit/review-details-form/review-details-form.component.ts b/src/frontend/src/app/pages/proposal-review-edit/review-details-form/review-details-form.component.ts index f9451234..b6a8aadd 100644 --- a/src/frontend/src/app/pages/proposal-review-edit/review-details-form/review-details-form.component.ts +++ b/src/frontend/src/app/pages/proposal-review-edit/review-details-form/review-details-form.component.ts @@ -13,8 +13,10 @@ import { Subscription } from 'rxjs'; import { ImageSet, ImageUploaderBtnComponent, + LoadingBtnComponent, RadioInputComponent, } from '@cg/angular-ui'; +import { ProposalReviewImage } from '~core/api'; import { ReviewSubmissionService } from '~core/state'; import { FormFieldComponent, @@ -37,10 +39,23 @@ import { boolToRadio, isNil, radioToBool, toSyncSignal } from '~core/utils'; InputDirective, RadioInputComponent, ImageUploaderBtnComponent, + LoadingBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + @use '@cg/styles/common'; + + .image-preview { + margin-top: common.size(4); + } + `, template: ` - + @let reviewForm = this.reviewForm(); + @let selectedImages = this.selectedImages(); + @let imagesSaving = this.imagesSaving(); + @let imagesDeleting = this.imagesDeleting(); + +
Summary
@@ -106,19 +121,36 @@ import { boolToRadio, isNil, radioToBool, toSyncSignal } from '~core/utils'; -
Build verification images
+
Build verification image
- Select image(s) + Select image
- @for (image of selectedImages(); track image.sm.url) { - + @for (image of selectedImages; track image.path) { +
+ + + + +
+ + Remove image + +
+
}
`, @@ -135,7 +167,10 @@ export class ReviewDetailsFormComponent implements OnDestroy { vote: new FormControl(null), }), ); - public selectedImages = signal([]); + + public readonly imagesSaving = signal(false); + public readonly imagesDeleting = signal(false); + public readonly selectedImages = signal([]); private formSubscription = new Subscription(); @@ -159,6 +194,7 @@ export class ReviewDetailsFormComponent implements OnDestroy { return; } + this.selectedImages.set(review.images); this.reviewForm().setValue( { summary: review.summary, @@ -174,8 +210,42 @@ export class ReviewDetailsFormComponent implements OnDestroy { this.formSubscription.unsubscribe(); } - public onImagesSelected(images: ImageSet[]): void { - this.selectedImages.set(images); + public async onImagesSelected(images: ImageSet[]): Promise { + this.imagesSaving.set(true); + const optimisticImages = images.map(image => ({ + path: image.xxl.url, + })); + this.selectedImages.set(optimisticImages); + + try { + await Promise.all( + images.map( + async image => + await this.reviewSubmissionService.createProposalReviewImage( + image.type, + image.xxl.bytes, + ), + ), + ); + } finally { + this.imagesSaving.set(false); + } + } + + public async removeImage(imagePath: string): Promise { + const images = this.selectedImages(); + if (images.length === 0) { + return; + } + + this.imagesDeleting.set(true); + + try { + await this.reviewSubmissionService.deleteProposalReviewImage(imagePath); + } finally { + this.selectedImages.set(images.slice(1)); + this.imagesDeleting.set(false); + } } private onFormValueChange( diff --git a/src/frontend/src/app/pages/proposal-review/proposal-review.component.ts b/src/frontend/src/app/pages/proposal-review/proposal-review.component.ts index eb3e14cf..f9d6d5da 100644 --- a/src/frontend/src/app/pages/proposal-review/proposal-review.component.ts +++ b/src/frontend/src/app/pages/proposal-review/proposal-review.component.ts @@ -14,6 +14,7 @@ import { BadgeComponent, CardComponent, CopyToClipboardComponent, + LinkTextBtnComponent, } from '@cg/angular-ui'; import { ProposalReviewStatus, ProposalState } from '~core/api'; import { ProfileService, ProposalService, ReviewService } from '~core/state'; @@ -35,35 +36,29 @@ import { isNil, isNotNil, routeParamSignal, toSyncSignal } from '~core/utils'; KeyValueGridComponent, ValueColComponent, KeyColComponent, + LinkTextBtnComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - @use '@cg/styles/common'; + styles: ` + @use '@cg/styles/common'; - :host { - @include common.page-content; - } - - .review__details, - .review__commit { - margin-bottom: common.size(6); - } + :host { + @include common.page-content; + } - .answer--positive { - color: common.$success; - } + .review__details, + .review__commit { + margin-bottom: common.size(6); + } - .answer--negative { - color: common.$error; - } + .answer--positive { + color: common.$success; + } - .review__image { - height: common.size(10); - padding-right: common.size(1); - } - `, - ], + .answer--negative { + color: common.$error; + } + `, template: ` @let review = this.currentReview(); @let proposal = this.currentProposal(); @@ -133,30 +128,24 @@ import { isNil, isNotNil, routeParamSignal, toSyncSignal } from '~core/utils'; - Build verification images + Build verification image - @for ( - image of review.reproducedBuildImageId; - track image.sm.url - ) { - - + @for (image of review.images; track image.path) { + + } @empty { - No build verification images were provided for this review. + No build verification image was provided for this review. }
@if (isReviewOwner && proposal.state === ProposalState.InProgress) { - + Edit review - + }
diff --git a/src/frontend/src/environments/environment.common.ts b/src/frontend/src/environments/environment.common.ts index 2fa3ad17..a7cc07d1 100644 --- a/src/frontend/src/environments/environment.common.ts +++ b/src/frontend/src/environments/environment.common.ts @@ -5,6 +5,10 @@ export const API_GATEWAY = IS_MAINNET : window.location.origin; export const CANISTER_ID_BACKEND = import.meta.CANISTER_ID_BACKEND ?? ''; +export const BACKEND_ORIGIN = IS_MAINNET + ? `https://${CANISTER_ID_BACKEND}.icp0.io` + : `http://${CANISTER_ID_BACKEND}.localhost:8080`; + export const CANISTER_ID_MARKETING = import.meta.CANISTER_ID_MARKETING ?? ''; export const CANISTER_ID_INTERNET_IDENTITY = diff --git a/src/frontend/src/environments/environment.development.ts b/src/frontend/src/environments/environment.development.ts index df46041d..c0666338 100644 --- a/src/frontend/src/environments/environment.development.ts +++ b/src/frontend/src/environments/environment.development.ts @@ -1,5 +1,6 @@ import { API_GATEWAY, + BACKEND_ORIGIN, CANISTER_ID_BACKEND, DERIVATION_ORIGIN, DFX_NETWORK, @@ -10,6 +11,7 @@ import { export const ENV = { API_GATEWAY, CANISTER_ID_BACKEND, + BACKEND_ORIGIN, DFX_NETWORK, IDENTITY_PROVIDER, IS_MAINNET, diff --git a/src/frontend/src/environments/environment.ts b/src/frontend/src/environments/environment.ts index df46041d..c0666338 100644 --- a/src/frontend/src/environments/environment.ts +++ b/src/frontend/src/environments/environment.ts @@ -1,5 +1,6 @@ import { API_GATEWAY, + BACKEND_ORIGIN, CANISTER_ID_BACKEND, DERIVATION_ORIGIN, DFX_NETWORK, @@ -10,6 +11,7 @@ import { export const ENV = { API_GATEWAY, CANISTER_ID_BACKEND, + BACKEND_ORIGIN, DFX_NETWORK, IDENTITY_PROVIDER, IS_MAINNET,