diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index f63b14cd95658b20dc7795ad8804b6e322020b50..1095b2dd6ede917c1a36f2768a39d6198d500716 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -8,6 +8,11 @@ { overflow: scroll!important; } + #root + { + width: 100%; + height: 100%; + } </style> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.5/dist/connectivity-component/connectivity-component.js" defer></script> diff --git a/src/atlasComponents/sapi/stories.base.ts b/src/atlasComponents/sapi/stories.base.ts index 57e28f20da83acf96a247117f70fb921998c3bf0..9b9037ba626ccfe7b05bcdd6750d6788a7fd6486 100644 --- a/src/atlasComponents/sapi/stories.base.ts +++ b/src/atlasComponents/sapi/stories.base.ts @@ -76,6 +76,11 @@ export async function getAtlas(id: string): Promise<SapiAtlasModel>{ return await (await fetch(`${endPt}/atlases/${id}`)).json() } +export async function getParcellations(atlasId: string): Promise<SapiParcellationModel[]> { + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations`)).json() +} + export async function getParc(atlasId: string, id: string): Promise<SapiParcellationModel>{ const endPt = await SAPI.BsEndpoint$.toPromise() return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}`)).json() @@ -85,6 +90,11 @@ export async function getParcRegions(atlasId: string, id: string, spaceId: strin return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}/regions?space_id=${encodeURIComponent(spaceId)}`)).json() } +export async function getSpaces(atlasId: string): Promise<SapiSpaceModel[]> { + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId}/spaces`)).json() +} + export async function getSpace(atlasId: string, id: string): Promise<SapiSpaceModel> { const endPt = await SAPI.BsEndpoint$.toPromise() return await (await fetch(`${endPt}/atlases/${atlasId}/spaces/${id}`)).json() diff --git a/src/atlasComponents/sapiViews/core/parcellation/index.ts b/src/atlasComponents/sapiViews/core/parcellation/index.ts index 801b1f3e381a6a806c94cd6458e3187a620755c1..78616fb9ba32c12462740f59fcc315a93e0da45e 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/index.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/index.ts @@ -1,11 +1,6 @@ -export { - SapiViewsCoreParcellationModule -} from "./module" - -export { - FilterGroupedParcellationPipe -} from "./filterGroupedParcellations.pipe" - -export { - FilterUnsupportedParcPipe -} from "./filterUnsupportedParc.pipe" \ No newline at end of file +export { SapiViewsCoreParcellationModule } from "./module" +export { FilterGroupedParcellationPipe } from "./filterGroupedParcellations.pipe" +export { FilterUnsupportedParcPipe } from "./filterUnsupportedParc.pipe" +export { GroupedParcellation } from "./groupedParcellation" +export { ParcellationDoiPipe } from "./parcellationDoi.pipe" +export { ParcellationGroupSelectedPipe } from "./parcellationGroupSelected.pipe" diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd31e439157a8e34182c03427fb70c35031241b9 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { SapiParcellationModel } from "src/atlasComponents/sapi/type"; +import { GroupedParcellation } from "./groupedParcellation"; + +function isGroupedParc(parc: GroupedParcellation|unknown): parc is GroupedParcellation { + if (!parc['parcellations']) return false + return (parc['parcellations'] as SapiParcellationModel[]).every(p => p["@type"] === "minds/core/parcellationatlas/v1.0.0") +} + +@Pipe({ + name: 'parcellationGroupSelected', + pure: true +}) + +export class ParcellationGroupSelectedPipe implements PipeTransform { + public transform(parc: GroupedParcellation|unknown, selectedParcellation: SapiParcellationModel): boolean { + if (!isGroupedParc(parc)) return false + return parc.parcellations.some(p => p["@id"] === selectedParcellation["@id"]) + } +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a8a2025ad72387c5dcb03972b800f9801f9500c --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts @@ -0,0 +1,3 @@ +export { + ATPSelectorModule +} from "./module" \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d64b311aa6a241d4d3c13251b7a3b7cc365632 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts @@ -0,0 +1,41 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { MatButtonModule } from "@angular/material/button"; +import { MatRippleModule } from "@angular/material/core"; +import { MatIconModule } from "@angular/material/icon"; +import { MarkdownModule } from "src/components/markdown"; +import { SmartChipModule } from "src/components/smartChip"; +import { DialogModule } from "src/ui/dialogInfo"; +import { UtilModule } from "src/util"; +import { FilterGroupedParcellationPipe, ParcellationDoiPipe, ParcellationGroupSelectedPipe } from "src/atlasComponents/sapiViews/core/parcellation"; +import { PureATPSelector } from "./pureDumb/pureATPSelector.components"; +import { WrapperATPSelector } from "./wrapper/wrapper.component"; +import { SAPIModule } from "src/atlasComponents/sapi/module"; + +@NgModule({ + imports: [ + CommonModule, + SmartChipModule, + UtilModule, + MarkdownModule, + MatRippleModule, + MatIconModule, + MatButtonModule, + DialogModule, + SAPIModule, + ], + declarations: [ + PureATPSelector, + WrapperATPSelector, + + FilterGroupedParcellationPipe, + ParcellationDoiPipe, + ParcellationGroupSelectedPipe, + ], + exports: [ + PureATPSelector, + WrapperATPSelector, + ] +}) + +export class ATPSelectorModule{} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts new file mode 100644 index 0000000000000000000000000000000000000000..99dbb387baa78c02dc0f8f4eb5a9d50f0a49f188 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; +import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type"; +import { FilterGroupedParcellationPipe, GroupedParcellation } from "src/atlasComponents/sapiViews/core/parcellation"; + +export const defaultColorPalette = [ + "#480202", + "#6b1205", + "#921d1d", +] + +export type ATP = { + atlas: SapiAtlasModel + template: SapiSpaceModel + parcellation: SapiParcellationModel +} +const pipe = new FilterGroupedParcellationPipe() + + +@Component({ + selector: 'sxplr-pure-atp-selector', + templateUrl: `./pureATPSelector.template.html`, + styleUrls: [ + `./pureATPSelector.style.scss` + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) + +export class PureATPSelector implements OnChanges{ + + @Input('sxplr-pure-atp-selector-color-palette') + colorPalette: string[] = defaultColorPalette + + @Input(`sxplr-pure-atp-selector-selected-atp`) + public selectedATP: ATP + + public selectedIds: string[] = [] + + @Input(`sxplr-pure-atp-selector-atlases`) + public allAtlases: SapiAtlasModel[] = [] + + @Input(`sxplr-pure-atp-selector-templates`) + public availableTemplates: SapiSpaceModel[] = [] + + @Input(`sxplr-pure-atp-selector-parcellations`) + public parcellations: SapiParcellationModel[] = [] + + public parcAndGroup: (GroupedParcellation|SapiParcellationModel)[] = [] + + @Input('sxplr-pure-atp-selector-is-busy') + public isBusy: boolean = false + + @Output('sxplr-pure-atp-selector-on-select') + selectLeafEmitter = new EventEmitter<ATP>() + + getChildren(parc: GroupedParcellation|SapiParcellationModel){ + return (parc as GroupedParcellation).parcellations || [] + } + + selectLeaf(atp: ATP) { + if (this.isBusy) return + this.selectLeafEmitter.emit(atp) + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.selectedATP) { + if (!changes.selectedATP.currentValue) { + this.selectedIds = [] + } else { + const { atlas, parcellation, template } = changes.selectedATP.currentValue as ATP + this.selectedIds = [atlas?.["@id"], parcellation?.["@id"], template?.["@id"]].filter(v => !!v) + } + } + + if (changes.parcellations) { + if (!changes.parcellations.currentValue) { + this.parcAndGroup = [] + } else { + this.parcAndGroup = [ + ...pipe.transform(changes.parcellations.currentValue, true), + ...pipe.transform(changes.parcellations.currentValue, false), + ] + } + } + } +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb0eeafc5e4bb5fb953ecfb011a85d1e8128a066 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts @@ -0,0 +1,139 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { MatButtonModule } from "@angular/material/button"; +import { action } from "@storybook/addon-actions"; +import { componentWrapperDecorator, Meta, moduleMetadata, Story } from "@storybook/angular"; +import { atlasId, provideDarkTheme } from "src/atlasComponents/sapi/stories.base"; +import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type"; +import { UtilModule } from "src/util"; +import { ATPSelectorModule } from "../module"; +import { defaultColorPalette } from "./pureATPSelector.components" +import { loadAtlasEtcData, wrapperDecoratorFn } from "../story.base" + +@Component({ + selector: 'atlas-selector-wrapper-story', + template: + `<sxplr-pure-atp-selector + [sxplr-pure-atp-selector-color-palette]="[atlasColor, spaceColor, parcellationColor]" + [sxplr-pure-atp-selector-selected-atp]="selectedATP" + [sxplr-pure-atp-selector-atlases]="allAtlases" + [sxplr-pure-atp-selector-templates]="availableTemplates" + [sxplr-pure-atp-selector-parcellations]="parcs" + [sxplr-pure-atp-selector-is-busy]="isBusy" + (sxplr-pure-atp-selector-on-select)="selectLeaf($event)" + > + <button mat-icon-button + (click)="toggleParcellationVisibility()" + parcellation-chip-suffix + iav-stop="mousedown click"> + <i class="fas fa-eye"></i> + </button> + </sxplr-pure-atp-selector>`, + styles: [ + `[parcellation-chip-suffix] + { + margin-right: -1rem; + margin-left: 0.2rem; + }` + ] +}) +class AtlasLayerSelectorWrapper { + atlasColor: string = defaultColorPalette[0] + spaceColor: string = defaultColorPalette[1] + parcellationColor: string = defaultColorPalette[2] + allAtlases: SapiAtlasModel[] + availableTemplates: SapiSpaceModel[] + selectedATP: { + atlas: SapiAtlasModel + parcellation: SapiParcellationModel + template: SapiSpaceModel + } + parcs: SapiParcellationModel[] + isBusy: boolean = false + + selectLeaf(arg: { + atlas: SapiAtlasModel + parcellation: SapiParcellationModel + template: SapiSpaceModel + }) {} + + toggleParcellationVisibility() { + + } +} + +export default { + component: AtlasLayerSelectorWrapper, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + ATPSelectorModule, + MatButtonModule, + UtilModule, + ], + declarations: [ + AtlasLayerSelectorWrapper + ], + providers: [ + ...provideDarkTheme, + ] + }), + componentWrapperDecorator(wrapperDecoratorFn) + ] +} as Meta + +const Template: Story<AtlasLayerSelectorWrapper> = (args: AtlasLayerSelectorWrapper, { loaded }) => { + + const { + combinedAtlas, + atlases + } = loaded + const { atlas, parcs, spaces } = combinedAtlas + const selectedATP = { + atlas, parcellation: parcs[0], template: spaces[0] + } + const { + atlasColor, + spaceColor, + parcellationColor, + } = args + return ({ + props: { + ...args, + allAtlases: atlases, + parcs, + availableTemplates: spaces, + selectedATP: { + atlas, + parcellation: parcs[0], + template: spaces[0] + }, + selectLeaf: action(`selectLeaf`), + toggleParcellationVisibility: action('toggleParcellationVisibility'), + atlasColor, + spaceColor, + parcellationColor, + } + }) +} + +export const Default = Template.bind({}) +Default.args = { + atlasColor: defaultColorPalette[0], + spaceColor: defaultColorPalette[1], + parcellationColor: defaultColorPalette[2], +} +Default.loaders = [ + async () => { + const { + atlases, + combinedAtlases + } = await loadAtlasEtcData() + + return { + atlases, + combinedAtlas: combinedAtlases.filter(v => v.atlas["@id"] === atlasId.human)[0] + } + } +] diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss new file mode 100644 index 0000000000000000000000000000000000000000..04f66421997ddc3e2c7f33812d52752aa0ba3595 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss @@ -0,0 +1,30 @@ +:host +{ + display: inline-flex; + flex-direction: row-reverse; +} + +.full-sized-button +{ + width: 100%; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.icons +{ + margin-right: -1rem; + margin-left: 0.2rem; +} + +sxplr-smart-chip:not(:last-child) +{ + margin-left: -3.5rem; + + .chip-text + { + padding-left: 1rem; + } +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html new file mode 100644 index 0000000000000000000000000000000000000000..cd071fbba5874bd6ff88d4a4538fbefc9b60a814 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html @@ -0,0 +1,122 @@ +<ng-template [ngIf]="selectedATP" let-ATP> + + <!-- parcellation smart chip --> + <sxplr-smart-chip *ngIf="ATP.parcellation" + [items]="parcAndGroup" + [color]="colorPalette[2]" + [getChildren]="getChildren" + (itemClicked)="selectLeaf({ parcellation: $event })" + [elevation]="2" + [disabled]="isBusy"> + <ng-template sxplrSmartChipContent> + <span class="chip-text"> + <span> + </span> + {{ ATP.parcellation.name }} + </span> + + <ng-content select="[parcellation-chip-suffix]"> + </ng-content> + + <button iav-stop="mousedown click" + class="icons" + mat-icon-button + sxplr-dialog + [sxplr-dialog-size]="null" + [sxplr-dialog-data]="{ + title: ATP.parcellation.name || ATP.parcellation.fullName, + descMd: (ATP.parcellation.brainAtlasVersions || [])[0]?.versionInnovation, + actions: ATP.parcellation | parcellationDoiPipe + }"> + <i class="fas fa-info"></i> + </button> + </ng-template> + <ng-template sxplrSmartChipMenu let-parc> + + <ng-container *ngTemplateOutlet="optionTmpl; context: { + $implicit: parc, + overridePrefixIconTmpl: (parc | parcellationGroupSelected : ATP.parcellation) + ? halfSelectedTmpl + : null + }"> + </ng-container> + </ng-template> + </sxplr-smart-chip> + + <!-- space smart chip --> + <sxplr-smart-chip *ngIf="ATP.template" + [items]="availableTemplates" + [color]="colorPalette[1]" + (itemClicked)="selectLeaf({ template: $event })" + [elevation]="4" + [disabled]="isBusy"> + <ng-template sxplrSmartChipContent> + <span class="chip-text"> + {{ ATP.template.fullName }} + </span> + </ng-template> + <ng-template sxplrSmartChipMenu let-space> + <ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: space }"></ng-container> + </ng-template> + </sxplr-smart-chip> + + <!-- atlas smart chip --> + <sxplr-smart-chip *ngIf="ATP.atlas" + [items]="allAtlases" + [color]="colorPalette[0]" + (itemClicked)="selectLeaf({ atlas: $event})" + [elevation]="6" + [disabled]="isBusy"> + <ng-template sxplrSmartChipContent> + <span class="chip-text"> + {{ ATP.atlas.name }} + </span> + </ng-template> + <ng-template sxplrSmartChipMenu let-atlas> + <ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: atlas }"></ng-container> + </ng-template> + </sxplr-smart-chip> +</ng-template> + +<!-- half selected --> +<!-- only active in nested menus (e.g. parcellation groups) --> +<ng-template #halfSelectedTmpl> + <mat-icon fontSet="far" fontIcon="fa-circle"></mat-icon> +</ng-template> + +<!-- option template --> +<ng-template + #optionTmpl + let-item + let-overridePrefixIconTmpl="overridePrefixIconTmpl" + let-overrideSuffixIcon="overrideSuffixIcon"> + + <!-- prefix --> + <ng-template [ngIf]="overridePrefixIconTmpl" [ngIfElse]="defaultPrefix"> + <ng-template [ngTemplateOutlet]="overridePrefixIconTmpl"></ng-template> + </ng-template> + <ng-template #defaultPrefix> + <ng-template [ngIf]="selectedIds" let-selectedIds> + <mat-icon + fontSet="fas" + [fontIcon]="selectedIds.includes(item['@id']) ? 'fa-circle' : 'fa-none'" + > + </mat-icon> + </ng-template> + </ng-template> + + <!-- button body --> + <span *ngIf="item; else noItemTmpl" class="full-sized-button"> + {{ item.name || item.fullName }} + </span> + + <!-- suffix --> + <ng-template [ngIf]="overrideSuffixIcon"> + <i [class]="overrideSuffixIcon"></i> + </ng-template> +</ng-template> + + +<ng-template #isBusyTmpl> + +</ng-template> \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts new file mode 100644 index 0000000000000000000000000000000000000000..86b1eae6592a5fb89fdea156bafa6b6d696b0efe --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts @@ -0,0 +1,42 @@ +import { getAtlases, getParcellations, getSpaces } from "src/atlasComponents/sapi/stories.base" +import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type" + +export const wrapperDecoratorFn = (story: string) => ` + <style> + .wrapper { + width: 100%; + height: 100%; + background-color: white; + + display: flex; + flex-direction: column-reverse; + border: 1px solid rgba(128, 128, 128, 0.5); + } + :host-context([darktheme="true"]) .wrapper + { + background-color: #2f2f2f; + } + </style> + <div class="wrapper">${story}</div> +` + +export type ReturnAtlas = { + atlas: SapiAtlasModel + spaces: SapiSpaceModel[] + parcs: SapiParcellationModel[] +} + +export const loadAtlasEtcData = async () => { + const combinedAtlases: ReturnAtlas[] = [] + const atlases = await getAtlases() + for (const atlas of atlases) { + const parcs = await getParcellations(atlas["@id"]) + const spaces = await getSpaces(atlas["@id"]) + combinedAtlases.push({ + atlas, + parcs, + spaces + }) + } + return { combinedAtlases, atlases } +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b4bfe6c852be9215f343b9263bc3fdb613796c3 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts @@ -0,0 +1,137 @@ +import { Component, OnDestroy } from "@angular/core"; +import { MatDialog } from "@angular/material/dialog"; +import { Store } from "@ngrx/store"; +import { Observable, of, Subject, Subscription } from "rxjs"; +import { filter, map, switchMap, tap, withLatestFrom } from "rxjs/operators"; +import { SAPI } from "src/atlasComponents/sapi/sapi.service"; +import { ParcellationSupportedInSpacePipe } from "src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe"; +import { atlasSelection } from "src/state"; +import { fromRootStore } from "src/state/atlasSelection"; +import { DialogFallbackCmp } from "src/ui/dialogInfo"; +import { ParcellationVisibilityService } from "../../../parcellation/parcellationVis.service"; +import { defaultColorPalette, ATP } from "../pureDumb/pureATPSelector.components" + +function isATPGuard(obj: any): obj is ATP { + if (!obj) return false + return obj.atlas || obj.template || obj.parcellation +} + +@Component({ + selector: 'sxplr-wrapper-atp-selector', + templateUrl: './wrapper.template.html', + styleUrls: [ + `./wrapper.style.css` + ] +}) + +export class WrapperATPSelector implements OnDestroy{ + defaultColorPalette = defaultColorPalette + + #subscription: Subscription[] = [] + #parcSupportedInSpacePipe = new ParcellationSupportedInSpacePipe(this.sapi) + + #askUser(title: string, descMd: string): Observable<boolean> { + const agree = "OK" + return this.dialog.open(DialogFallbackCmp, { + data: { + title, + descMd, + actions: [agree] + } + }).afterClosed().pipe( + map(val => val === agree) + ) + } + + selectedATP$ = this.store$.pipe( + fromRootStore.distinctATP(), + ) + + allAtlases$ = this.sapi.atlases$ + availableTemplates$ = this.store$.pipe( + fromRootStore.allAvailSpaces(this.sapi), + ) + parcs$ = this.store$.pipe( + fromRootStore.allAvailParcs(this.sapi), + ) + isBusy$ = new Subject<boolean>() + + parcellationVisibility$ = this.svc.visibility$ + + constructor( + private dialog: MatDialog, + private store$: Store, + private sapi: SAPI, + private svc: ParcellationVisibilityService, + ){ + this.#subscription.push( + this.selectLeaf$.pipe( + tap(() => this.isBusy$.next(true)), + withLatestFrom(this.selectedATP$), + switchMap(([{ atlas, template, parcellation }, selectedATP]) => { + if (atlas) { + /** + * do not need to ask permission to switch atlas + */ + return of({ atlas }) + } + if (template) { + return this.#parcSupportedInSpacePipe.transform(selectedATP.parcellation, template).pipe( + switchMap(({ supported }) => supported + ? of({ template }) + : this.#askUser(`Incompatible parcellation`, `Attempting to load template **${template.fullName}**, which does not support parcellation **${selectedATP.parcellation.name}**. Proceed anyway and load the default parcellation?`).pipe( + switchMap(flag => of(flag ? { template } : null)) + )) + ) + } + if (parcellation) { + return this.#parcSupportedInSpacePipe.transform(parcellation, selectedATP.template).pipe( + switchMap(({ supported }) => supported + ? of({ parcellation }) + : this.#askUser(`Incompatible template`, `Attempting to load parcellation **${parcellation.name}**, which is not supported in template **${selectedATP.template.fullName}**. Proceed anyway and load the default template?`).pipe( + switchMap(flag => of(flag ? { parcellation } : null)) + )) + ) + } + return of(null) + }), + filter(val => { + this.isBusy$.next(false) + return !!val + }) + ).subscribe((obj) => { + + if (!isATPGuard(obj)) return + const { atlas, parcellation, template } = obj + if (atlas) { + this.store$.dispatch( + atlasSelection.actions.selectAtlas({ atlas }) + ) + } + if (parcellation) { + this.store$.dispatch( + atlasSelection.actions.selectParcellation({ parcellation }) + ) + } + if (template) { + this.store$.dispatch( + atlasSelection.actions.selectTemplate({ template }) + ) + } + }) + ) + } + + private selectLeaf$ = new Subject<ATP>() + selectLeaf(atp: ATP) { + this.selectLeaf$.next(atp) + } + + toggleParcellationVisibility() { + this.svc.toggleVisibility() + } + + ngOnDestroy(): void { + while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe() + } +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dbf609657ac3efc72d6a2cc24c9b35523528d55 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts @@ -0,0 +1,81 @@ +import { Component } from "@angular/core"; +import { MatDialogModule } from "@angular/material/dialog"; +import { Store } from "@ngrx/store"; +import { action } from "@storybook/addon-actions"; +import { componentWrapperDecorator, Meta, moduleMetadata, Story } from "@storybook/angular"; +import { atlasId, provideDarkTheme } from "src/atlasComponents/sapi/stories.base"; +import { atlasSelection, RootStoreModule } from "src/state"; +import { ParcellationVisibilityService } from "../../../parcellation/parcellationVis.service"; +import { ATPSelectorModule } from "../module"; +import { ATP } from "../pureDumb/pureATPSelector.components"; +import { loadAtlasEtcData, wrapperDecoratorFn } from "../story.base"; + +@Component({ + selector: 'wrapper-wrapper', + template: '<sxplr-wrapper-atp-selector></sxplr-wrapper-atp-selector>' +}) +class Wrapper{ + set ATP(atp: ATP) { + const { atlas, parcellation, template } = atp + + this.store$.dispatch( + atlasSelection.actions.setAtlasSelectionState({ + selectedAtlas: atlas, + selectedTemplate: template, + selectedParcellation: parcellation + }) + ) + + this.store$.dispatch = action('dispatch') + } + + constructor(private store$: Store) {} +} + +export default { + component: Wrapper, + decorators: [ + moduleMetadata({ + imports: [ + ATPSelectorModule, + MatDialogModule, + RootStoreModule, + ], + providers: [ + ...provideDarkTheme, + ParcellationVisibilityService, + ] + }), + componentWrapperDecorator(wrapperDecoratorFn) + ] +} as Meta + +const Template: Story<Wrapper> = (args: Wrapper, { loaded }) => { + const { combinedAtlas } = loaded + return { + props: { + ...args, + ATP: { + atlas: combinedAtlas.atlas, + parcellation: combinedAtlas.parcs[0], + template: combinedAtlas.spaces[0], + } + } + } +} + +export const Default = Template.bind({}) + +Default.loaders = [ + async () => { + const { + atlases, + combinedAtlases + } = await loadAtlasEtcData() + + return { + atlases, + combinedAtlas: combinedAtlases.filter(v => v.atlas["@id"] === atlasId.human)[0] + } + } +] diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css new file mode 100644 index 0000000000000000000000000000000000000000..a738cc567de677401989a85134815ceb729bd540 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css @@ -0,0 +1,5 @@ +[parcellation-chip-suffix] +{ + margin-right: -1rem; + margin-left: 0.2rem; +} diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html new file mode 100644 index 0000000000000000000000000000000000000000..52b49096e748fbc92038003077f9ea6abadda111 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html @@ -0,0 +1,18 @@ +<sxplr-pure-atp-selector + [sxplr-pure-atp-selector-color-palette]="defaultColorPalette" + [sxplr-pure-atp-selector-selected-atp]="selectedATP$ | async" + [sxplr-pure-atp-selector-atlases]="allAtlases$ | async" + [sxplr-pure-atp-selector-templates]="availableTemplates$ | async" + [sxplr-pure-atp-selector-parcellations]="parcs$ | async" + [sxplr-pure-atp-selector-is-busy]="isBusy$ | async" + (sxplr-pure-atp-selector-on-select)="selectLeaf($event)" + > + <button mat-icon-button + (click)="toggleParcellationVisibility()" + parcellation-chip-suffix + iav-stop="mousedown click" + [iav-key-listener]="[{'type': 'keydown', 'key': 'q', 'capture': true, 'target': 'document' }]" + (iav-key-event)="toggleParcellationVisibility()" > + <i class="fas" [ngClass]="(parcellationVisibility$ | async) ? 'fa-eye' : 'fa-eye-slash'"></i> + </button> +</sxplr-pure-atp-selector> diff --git a/src/components/smartChip/component/smartChip.component.ts b/src/components/smartChip/component/smartChip.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..d076f2ddb0fbcc3a0a7800a9088662b90c722744 --- /dev/null +++ b/src/components/smartChip/component/smartChip.component.ts @@ -0,0 +1,88 @@ +import { ChangeDetectionStrategy, Component, ContentChild, EventEmitter, HostBinding, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; +import { SmartChipContent } from "../smartChip.content.directive" +import { SmartChipMenu } from "../smartChip.menu.directive"; +import { rgbToHsl, hexToRgb } from 'common/util' + +const cssColorIsDark = (input: string) => { + if (/rgb/i.test(input)) { + const match = /\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(input) + if (!match) throw new Error(`rgb cannot be extracted ${input}`) + const rgb = [ + parseInt(match[1]), + parseInt(match[2]), + parseInt(match[3]), + ] + const [_h, _s, l] = rgbToHsl(...rgb) + return l < 0.4 + } + if (/hsl/i.test(input)) { + const match = /\((.*)\)/.exec(input) + const [h, s, l] = match[1].split(",") + const trimmedL = l.trim() + if (/\%$/.test(trimmedL)) { + const match = /^([0-9]+)\%/.exec(trimmedL) + return (parseInt(match[1]) / 100) < 0.4 + } + } + if (/^#/i.test(input) && input.length === 7) { + const [r, g, b] = hexToRgb(input) + const [_h, _s, l] = rgbToHsl(r, g, b) + return l < 0.6 + } + throw new Error(`Cannot parse css color: ${input}`) +} + +@Component({ + selector: `sxplr-smart-chip`, + templateUrl: `./smartChip.template.html`, + styleUrls: [ + `/smartChip.style.css` + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class SmartChip<T extends object> implements OnChanges{ + @Input('color') + color = `rgba(200, 200, 200, 1)` + + @Input('disabled') + disabled: boolean = false + + @Input('elevation') + elevation: number = 4 // translates to mat-elevation-z{elevation} + + smartChipClass = 'mat-elevation-z4' + + @Input('items') + items: T[] = [] + + @Input('getChildren') + getChildren: (item: T) => T[] = item => item['children'] || [] + + @Output('itemClicked') + itemClicked = new EventEmitter<T>() + + @ContentChild(SmartChipContent) + contentTmpl: SmartChipContent + + @ContentChild(SmartChipMenu) + menuTmpl: SmartChipMenu + + @HostBinding('class') + darkTheme: string = 'lighttheme' + + ngOnChanges(simpleChanges: SimpleChanges) { + if (simpleChanges.color) { + this.darkTheme = cssColorIsDark(this.color) + ? 'darktheme' + : 'lighttheme' + } + + if (simpleChanges.disabled || simpleChanges.elevation) { + this.smartChipClass = [ + this.disabled ? 'disabled' : null, + `mat-elevation-z${this.elevation}` + ].filter(v => !!v).join(' ') + } + } +} diff --git a/src/components/smartChip/component/smartChip.style.css b/src/components/smartChip/component/smartChip.style.css new file mode 100644 index 0000000000000000000000000000000000000000..412628c323bea965f7bdcce09161f031c537b6be --- /dev/null +++ b/src/components/smartChip/component/smartChip.style.css @@ -0,0 +1,34 @@ +.smart-chip +{ + display: inline-block; + padding: 0.5rem 1rem; + margin: 0.5rem 1rem; + border-radius: 1rem; + height: 1.25rem; + + display: inline-flex; + flex-direction: row; + align-items: center; +} + +.smart-chip +{ + opacity: 1.0; + transition: opacity 160ms ease-in-out; +} + +.smart-chip:not(.disabled):hover +{ + cursor: default; +} + +.smart-chip.disabled +{ + opacity: 0.5; + pointer-events: none; +} + +.smart-chip.disabled:hover +{ + cursor: not-allowed; +} diff --git a/src/components/smartChip/component/smartChip.template.html b/src/components/smartChip/component/smartChip.template.html new file mode 100644 index 0000000000000000000000000000000000000000..c82394e1d9f2157ffb874af1d41789fb7e52c6aa --- /dev/null +++ b/src/components/smartChip/component/smartChip.template.html @@ -0,0 +1,61 @@ +<div [style.background-color]="color" + [matMenuTriggerFor]="mainMenu" + matRipple + [ngClass]="smartChipClass" + class="mat-body smart-chip sxplr-custom-cmp text"> + <ng-template [ngTemplateOutlet]="contentTmpl?.templateRef || fallbackContentTmpl"> + </ng-template> +</div> + +<!-- main menu is fired from chip --> +<mat-menu #mainMenu="matMenu"> + <ng-template ngFor [ngForOf]="items" let-item> + + <!-- if item is has submenu --> + <ng-template [ngIf]="item | hasSubMenu : getChildren" [ngIfElse]="noSubMenuTmpl"> + <button + mat-menu-item + [matMenuTriggerFor]="subMenu" + [matMenuTriggerData]="{ $implicit: getChildren(item) }"> + <ng-container *ngTemplateOutlet="menuTmpl?.templateRef || fallbackMenu; context: { + $implicit: item + }"> + </ng-container> + </button> + + </ng-template> + + <!-- if item has no submenu --> + <ng-template #noSubMenuTmpl> + <ng-container *ngTemplateOutlet="leafTmpl; context: { $implicit: item }"></ng-container> + </ng-template> + + </ng-template> +</mat-menu> + +<!-- submenu (fired from menu item) --> +<mat-menu #subMenu="matMenu"> + <ng-template matMenuContent let-items> + <ng-template ngFor [ngForOf]="items" let-item> + <ng-container *ngTemplateOutlet="leafTmpl; context: { $implicit: item }"></ng-container> + </ng-template> + </ng-template> +</mat-menu> + +<!-- template to render the leaf nodes --> +<ng-template #leafTmpl let-item> + <button mat-menu-item (click)="itemClicked.emit(item)"> + <ng-container *ngTemplateOutlet="menuTmpl?.templateRef || fallbackMenu; context: { + $implicit: item + }"> + </ng-container> + </button> +</ng-template> + +<ng-template #fallbackContentTmpl> + Fallback Content +</ng-template> + +<ng-template #fallbackMenu let-item> + {{ item.name || 'Item Name' }} +</ng-template> diff --git a/src/components/smartChip/hasSubmenu.pipe.ts b/src/components/smartChip/hasSubmenu.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..32553eb6302e00ee6ae662b20b1950d6128dc76a --- /dev/null +++ b/src/components/smartChip/hasSubmenu.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'hasSubMenu', + pure: true +}) + +export class HasSubMenuPipe<T extends object> implements PipeTransform{ + public transform(item: T, getChildren: (obj: T) => T[]): boolean { + return (getChildren(item) || []).length > 0 + } +} diff --git a/src/components/smartChip/index.ts b/src/components/smartChip/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd61c840454ef0455d4a6253fa4b90133d4ddf51 --- /dev/null +++ b/src/components/smartChip/index.ts @@ -0,0 +1,3 @@ +export { SmartChipModule } from "./module" +export { SmartChip } from "./component/smartChip.component" +export { SmartChipContent } from "./smartChip.content.directive" diff --git a/src/components/smartChip/module.ts b/src/components/smartChip/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..00fe423cc79816bf0b9cb871e3c8392aacc09520 --- /dev/null +++ b/src/components/smartChip/module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { MatRippleModule } from "@angular/material/core"; +import { MatMenuModule } from "@angular/material/menu"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { SmartChip } from "./component/smartChip.component"; +import { HasSubMenuPipe } from "./hasSubmenu.pipe"; +import { SmartChipContent } from "./smartChip.content.directive"; +import { SmartChipMenu } from "./smartChip.menu.directive"; + +@NgModule({ + imports: [ + CommonModule, + MatMenuModule, + BrowserAnimationsModule, + MatRippleModule, + ], + declarations: [ + SmartChipMenu, + SmartChipContent, + SmartChip, + HasSubMenuPipe, + ], + exports: [ + SmartChipMenu, + SmartChipContent, + SmartChip, + ] +}) + +export class SmartChipModule{} diff --git a/src/components/smartChip/smartChip.content.directive.ts b/src/components/smartChip/smartChip.content.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..4026fdf61f22814fe2f7cd05b42662089b82018b --- /dev/null +++ b/src/components/smartChip/smartChip.content.directive.ts @@ -0,0 +1,9 @@ +import { Directive, Inject, TemplateRef } from "@angular/core"; + +@Directive({ + selector: `ng-template[sxplrSmartChipContent]` +}) + +export class SmartChipContent { + constructor(@Inject(TemplateRef) public templateRef: TemplateRef<unknown>){} +} diff --git a/src/components/smartChip/smartChip.menu.directive.ts b/src/components/smartChip/smartChip.menu.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..138e5fdc04012d2b9e9ced5492f997c98228533a --- /dev/null +++ b/src/components/smartChip/smartChip.menu.directive.ts @@ -0,0 +1,9 @@ +import { Directive, Inject, TemplateRef } from "@angular/core"; + +@Directive({ + selector: `ng-template[sxplrSmartChipMenu]` +}) + +export class SmartChipMenu { + constructor(@Inject(TemplateRef) public templateRef: TemplateRef<unknown>){} +} diff --git a/src/components/smartChip/smartChip.stories.ts b/src/components/smartChip/smartChip.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..791c9372e8a71318b0633d00e75ce7de003d7a50 --- /dev/null +++ b/src/components/smartChip/smartChip.stories.ts @@ -0,0 +1,160 @@ +import { CommonModule } from "@angular/common"; +import { Component, Pipe, PipeTransform } from "@angular/core"; +import { MatDividerModule } from "@angular/material/divider"; +import { MatExpansionModule } from "@angular/material/expansion"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { provideDarkTheme } from "src/atlasComponents/sapi/stories.base"; +import { SmartChipModule } from "./module"; + + +const complex1 = ` +<sxplr-smart-chip [color]="color" [items]="inventory"> + <span *sxplrSmartChipContent>menu example</span> + <ng-template sxplrSmartChipMenu let-inv> + {{ inv.name }} + </ng-template> +</sxplr-smart-chip> +` + +const complex2 = ` + +<sxplr-smart-chip [color]="color" [items]="categories" [getChildren]="getChildren"> + <span *sxplrSmartChipContent>submenu example</span> + <ng-template sxplrSmartChipMenu let-item> + {{ item.categoryName || item.name }} + </ng-template> +</sxplr-smart-chip> +` + +const input = { + complex1, + complex2, +} + +const getHtmlSnippet = (key: string) => ` +<mat-expansion-panel> +<mat-expansion-panel-header> + <mat-panel-title> + Code + </mat-panel-title> +</mat-expansion-panel-header> +<pre> + <code [innerHTML]="input.${key} | showHtmlCode"> + </code> +</pre> +</mat-expansion-panel> +${input[key]} +` + +@Pipe({ + name: 'showHtmlCode', + pure: true +}) +class ShowHtmlCode implements PipeTransform { + public transform(input: string) { + return input + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + } +} + +type Inventory = { + name: string + quantity: number +} + +type Category = { + categoryName: string + inventories: Inventory[] +} + +@Component({ + selector: 'smart-chip-container', + template: ` + <div class="container"> + ${Object.keys(input).map(key => getHtmlSnippet(key)).join('\n<mat-divider></mat-divider>\n')} + </div> + `, + styles: [ + `.mat-divider{ margin: 1rem; } .container{ display: flex; flex-direction: column; }` + ] +}) +class SmartChipContainerCmp{ + color:string + + inventory: Inventory[] = [{ + name: 'banana', + quantity: 10, + }, { + name: 'apple', + quantity: 5, + }] + + categories: Category[] = [{ + categoryName: 'fruits', + inventories: this.inventory + }, { + categoryName: 'processed food', + inventories: [{ + name: 'pizza', + quantity: 1000, + }, { + name: 'kebab', + quantity: 12 + }] + }, { + categoryName: 'xmas food', + inventories: [] + }] + + getChildren(category: Category) { + return category.inventories + } + input = input +} + +export default { + component: SmartChipContainerCmp, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + SmartChipModule, + MatExpansionModule, + MatDividerModule, + ], + declarations: [ + ShowHtmlCode, + ], + providers: [ + ...provideDarkTheme, + ] + }) + ] +} as Meta + +const Template: Story<SmartChipContainerCmp> = (args: SmartChipContainerCmp) => { + const { color } = args + return ({ + props: { + color + } + }) +} + +export const Default = Template.bind({}) +Default.args = { + color: 'rgba(206, 221, 247, 1)', +} + + +// export const AtlasEtc = Template.bind({}) +// AtlasEtc.args = { +// color: 'rgba(206, 221, 247, 1)' +// } +// AtlasEtc.loaders = [ +// loadAtlasEtcData +// ] diff --git a/src/state/atlasSelection/util.ts b/src/state/atlasSelection/util.ts index cf64466bb4375a6ffa06bb50ec99fea60ee5b8fd..2ee06893cafa819dc5a13784f74a9425f6645804 100644 --- a/src/state/atlasSelection/util.ts +++ b/src/state/atlasSelection/util.ts @@ -1,32 +1,42 @@ import { createSelector, select } from "@ngrx/store"; -import { forkJoin, pipe } from "rxjs"; +import { forkJoin, of, pipe } from "rxjs"; import { distinctUntilChanged, map, switchMap } from "rxjs/operators"; import { SAPI, SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi"; import { jsonEqual } from "src/util/json"; import * as selectors from "./selectors" -const allAvailSpaces = (sapi: SAPI) => pipe( - select(selectors.selectedAtlas), - switchMap(atlas => forkJoin( - atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"]))) +const allAvailSpaces = (sapi: SAPI) => { + return pipe( + select(selectors.selectedAtlas), + switchMap(atlas => atlas + ? forkJoin( + atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"])) + ) + : of([]) + ) ) -) +} const allAvailParcs = (sapi: SAPI) => pipe( select(selectors.selectedAtlas), - switchMap(atlas => - forkJoin( + switchMap(atlas => atlas + ? forkJoin( atlas.parcellations.map(parcWId => sapi.getParcDetail(atlas["@id"], parcWId["@id"])) ) + : of([]) ) ) const allAvailSpacesParcs = (sapi: SAPI) => pipe( select(selectors.selectedAtlas), - switchMap(atlas => - forkJoin({ + switchMap(atlas => atlas + ? forkJoin({ spaces: atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"])), parcellation: atlas.parcellations.map(parcWId => sapi.getParcDetail(atlas["@id"], parcWId["@id"])), }) + : of({ + spaces: [], + parcellation: [] + }) ) ) diff --git a/src/ui/dialogInfo/dialog.directive.ts b/src/ui/dialogInfo/dialog.directive.ts index 63d886a6ef5fa172ea1a8cf17abb5587005a5751..eccb689f25f5e35c65375e3d5ebe32124f16c2b9 100644 --- a/src/ui/dialogInfo/dialog.directive.ts +++ b/src/ui/dialogInfo/dialog.directive.ts @@ -1,6 +1,6 @@ import { Directive, HostListener, Input, TemplateRef } from "@angular/core"; import { MatDialog, MatDialogConfig } from "@angular/material/dialog"; -import { MatSnackBar } from "@angular/material/snack-bar"; +import { DialogFallbackCmp } from "./tmpl/tmpl.component" type DialogSize = 's' | 'm' | 'l' | 'xl' @@ -39,20 +39,13 @@ export class DialogDirective{ @Input('sxplr-dialog-data') data: unknown - constructor( - private matDialog: MatDialog, - private snackbar: MatSnackBar, - ){ - } + constructor(private matDialog: MatDialog){} @HostListener('click') onClick(){ - if (!this.templateRef) { - return this.snackbar.open(`Cannot show dialog. sxplr-dialog template not provided`) - } - this.matDialog.open(this.templateRef, { + this.matDialog.open(this.templateRef || DialogFallbackCmp, { data: this.data, ...(sizeDict[this.size] || {}) }) } -} \ No newline at end of file +} diff --git a/src/ui/dialogInfo/index.ts b/src/ui/dialogInfo/index.ts index 354dde0cbdafd34674a303cfa52d0dd45aa3487f..8eae65c028d1384eb09ae8044ff9d6254eb43d95 100644 --- a/src/ui/dialogInfo/index.ts +++ b/src/ui/dialogInfo/index.ts @@ -1 +1,3 @@ -export { DialogDirective } from "./dialog.directive" \ No newline at end of file +export { DialogDirective } from "./dialog.directive" +export { DialogModule } from "./module" +export { DialogFallbackCmp } from "./tmpl/tmpl.component" diff --git a/src/ui/dialogInfo/module.ts b/src/ui/dialogInfo/module.ts index de09c12a621f36ca70b46354e7eda5a040802b70..82702efff50d103ec252164912342230bf830884 100644 --- a/src/ui/dialogInfo/module.ts +++ b/src/ui/dialogInfo/module.ts @@ -1,15 +1,23 @@ +import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { MatButtonModule } from "@angular/material/button"; import { MatDialogModule } from "@angular/material/dialog"; -import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { MarkdownModule } from "src/components/markdown"; +import { StrictLocalModule } from "src/strictLocal"; import { DialogDirective } from "./dialog.directive" +import { DialogFallbackCmp } from "./tmpl/tmpl.component"; @NgModule({ imports: [ - MatSnackBarModule, + CommonModule, MatDialogModule, + MatButtonModule, + MarkdownModule, + StrictLocalModule, ], declarations: [ DialogDirective, + DialogFallbackCmp, ], exports: [ DialogDirective, diff --git a/src/ui/dialogInfo/tmpl/tmpl.component.ts b/src/ui/dialogInfo/tmpl/tmpl.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5e2f505d75ace5f171b1f58094bbcd61f58bc49 --- /dev/null +++ b/src/ui/dialogInfo/tmpl/tmpl.component.ts @@ -0,0 +1,25 @@ +import { Component, Inject } from "@angular/core"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; + +export type FallBackData = { + title: string + actions?: string[] + desc?: string + descMd?: string +} + +@Component({ + selector: 'sxplr-dialog-fallback-tmpl', + templateUrl: './tmpl.template.html', + styleUrls: [ + './tmpl.style.css' + ], +}) + +export class DialogFallbackCmp { + constructor( + @Inject(MAT_DIALOG_DATA) public data: FallBackData + ){ + + } +} diff --git a/src/ui/dialogInfo/tmpl/tmpl.stories.ts b/src/ui/dialogInfo/tmpl/tmpl.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/ui/dialogInfo/tmpl/tmpl.style.css b/src/ui/dialogInfo/tmpl/tmpl.style.css new file mode 100644 index 0000000000000000000000000000000000000000..63e67f07fea53d98e2523f373fa3e82bb312f001 --- /dev/null +++ b/src/ui/dialogInfo/tmpl/tmpl.style.css @@ -0,0 +1,4 @@ +mat-dialog-actions a i +{ + margin-right: 0.5rem; +} diff --git a/src/ui/dialogInfo/tmpl/tmpl.template.html b/src/ui/dialogInfo/tmpl/tmpl.template.html new file mode 100644 index 0000000000000000000000000000000000000000..31807ec667f1fc53fb4d35bed82e9ed36c77f355 --- /dev/null +++ b/src/ui/dialogInfo/tmpl/tmpl.template.html @@ -0,0 +1,40 @@ +<h1 *ngIf="data.title" mat-dialog-title> + {{ data.title }} +</h1> + +<div *ngIf="data.descMd || data.desc" mat-dialog-content class="mat-body"> + <ng-template [ngIf]="data.desc"> + {{ data.desc }} + </ng-template> + + <markdown-dom *ngIf="data.descMd" [markdown]="data.descMd"> + </markdown-dom> +</div> + + +<mat-dialog-actions align="start"> + + <ng-template ngFor [ngForOf]="data.actions || []" let-action> + <a *ngIf="action.startsWith('http'); else defaultActionTmpl" + [href]="action" + sxplr-hide-when-local + target="_blank" + mat-raised-button + color="primary"> + <i class="fas fa-external-link-alt"></i> + <span>Detail</span> + </a> + + <ng-template #defaultActionTmpl> + <button mat-raised-button + color="primary" + [mat-dialog-close]="action"> + {{ action }} + </button> + </ng-template> + </ng-template> + + <button mat-dialog-close mat-button> + Close + </button> +</mat-dialog-actions> diff --git a/src/util/recursivePartial.ts b/src/util/recursivePartial.ts new file mode 100644 index 0000000000000000000000000000000000000000..6056208de8872d95a3e7fb8e143116bcaee463d4 --- /dev/null +++ b/src/util/recursivePartial.ts @@ -0,0 +1,3 @@ +export type RecursivePartial<T> = Partial<{ + [K in keyof T]: RecursivePartial<T[K]> +}>