Skip to content

Commit

Permalink
Merge pull request #340 from hubmapconsortium/cde-create-visualizatio…
Browse files Browse the repository at this point in the history
…n-page

Cde create visualization page
  • Loading branch information
axdanbol authored May 16, 2024
2 parents 4e22465 + 06847ff commit c89d973
Show file tree
Hide file tree
Showing 32 changed files with 1,370 additions and 31 deletions.
2 changes: 1 addition & 1 deletion apps/cde-ui/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export default {
displayName: 'cde-ui',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts', '@testing-library/jest-dom'],
coverageDirectory: '../../coverage/apps/cde-ui',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
Expand Down
4 changes: 2 additions & 2 deletions apps/cde-ui/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
"maximumWarning": "4kb",
"maximumError": "8kb"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion apps/cde-ui/src/_redirects
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/* /index.html 200
/* /index.html 200
2 changes: 0 additions & 2 deletions apps/cde-ui/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
<cde-header></cde-header>
<router-outlet></router-outlet>
<cde-footer></cde-footer>
7 changes: 7 additions & 0 deletions apps/cde-ui/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { Routes, provideRouter } from '@angular/router';
import { provideIcons } from '@hra-ui/cdk/icons';
import { CreateVisualizationPageComponent } from './pages/create-visualization-page/create-visualization-page.component';
import { LandingPageComponent } from './pages/landing-page/landing-page.component';

/**
Expand All @@ -12,6 +14,10 @@ const routes: Routes = [
path: 'home',
loadComponent: () => LandingPageComponent,
},
{
path: 'create',
loadComponent: () => CreateVisualizationPageComponent,
},
{
path: '**',
redirectTo: 'home',
Expand All @@ -24,6 +30,7 @@ const routes: Routes = [
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(),
provideIcons({
fontIcons: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { MarkEmptyFormControlDirective } from './empty-form-control.directive';

describe('EmptyFormControlDirective', () => {
it('initial state should have class "empty" when the control is empty', async () => {
await render(`<input type="text" cdeMarkEmptyFormControl [formControl]="control" data-testid="input" />`, {
imports: [MarkEmptyFormControlDirective, ReactiveFormsModule],
componentProperties: {
control: new FormControl(),
},
});

const input = screen.getByTestId('input');
expect(input).toHaveClass('empty');
});

it('should remove "empty" class when the control is not empty', async () => {
const user = userEvent.setup();
await render(`<input type="text" cdeMarkEmptyFormControl [formControl]="control" data-testid="input" />`, {
imports: [MarkEmptyFormControlDirective, ReactiveFormsModule],
componentProperties: {
control: new FormControl(),
},
});

const input = screen.getByTestId('input');
await user.type(input, 'Hello');

expect(input).not.toHaveClass('empty');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Directive, OnInit, inject, signal } from '@angular/core';
import { NgControl } from '@angular/forms';

/**
* Directive that adds an `empty` class to form control elements
* when the control has no value.
*/
@Directive({
selector: '[cdeMarkEmptyFormControl]',
standalone: true,
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: {
'[class.empty]': 'isEmpty()',
},
})
export class MarkEmptyFormControlDirective implements OnInit {
/** Whether the form is empty */
readonly isEmpty = signal(true);

/** Reference to the form control */
private readonly control = inject(NgControl, { self: true });

/** Binds the form control state to the isEmpty signal */
ngOnInit(): void {
this.control.valueChanges?.subscribe((value) => this.isEmpty.set(!value));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<div class="column-content">
@if (!file && fileTitle(); as title) {
<h3 class="subtitle">{{ fileTitle() }}</h3>
}
@if (file) {
<div class="upload-success">
<div class="filename">
@if (fileTitle(); as title) {
<h3 class="subtitle">File Loaded:</h3>
} @else {
<span class="loaded-label">File loaded:</span>
}
<span class="column-info-list mat-body-2 file-name">{{ file.name }}</span>
</div>
<button class="remove-file" mat-flat-button (click)="cancelLoad()" type="button" color="primary">
<mat-icon class="material-symbols-rounded">cancel</mat-icon>
Remove CSV
</button>
</div>
} @else {
<button class="upload" mat-flat-button (click)="fileInput.click()" type="button" color="primary">
<mat-icon class="material-symbols-rounded">upload</mat-icon>
Upload CSV
</button>
<input
#fileInput
(change)="load(fileInput)"
type="file"
[attr.accept]="accept()"
name="fileInput"
style="display: none"
/>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
:host {
display: block;

.upload-success {
display: flex;
justify-content: space-between;

.filename span {
display: block;
}

.file-name {
font-weight: 300;
}

.remove-file {
align-self: flex-end;
}
}

.upload-colormap .loaded-label {
font-weight: 500;
font-size: 1.5rem;
line-height: 2.25rem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FileLoaderOptions, FileLoaderResult, FileUploadComponent } from './file-upload.component';
import { render, screen } from '@testing-library/angular';
import '@testing-library/jest-dom';
import { signal } from '@angular/core';

describe('FileUploadComponent', () => {
let loader: jest.Mock<FileLoaderResult<unknown>, [File, FileLoaderOptions]>;

beforeEach(() => {
loader = jest.fn();
loader.mockReturnValue({
progress: signal(0),
result: Promise.resolve('abc'),
});
});

test('should load', async () => {
await render(FileUploadComponent, {
componentInputs: {
fileTitle: 'testTitle',
accept: 'csv',
loader: loader,
},
});

expect(screen.getByText(/testTitle/i)).toBeInTheDocument();
});
});
138 changes: 138 additions & 0 deletions apps/cde-ui/src/app/components/file-upload/file-upload.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Injector, Signal, effect, inject, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';

/** Loader options */
export interface FileLoaderOptions {
/** Signal to stop loading */
signal: AbortSignal;
}

/** Result from a file loader callback */
export interface FileLoaderResult<T> {
/** Signal of current progress in range [0, 1] */
progress: Signal<number>;
/** Promise resolving to the loaded data */
result: Promise<T>;
}

/** File loader function */
export type FileLoader<T> = (file: File, options: FileLoaderOptions) => FileLoaderResult<T>;

/** Cleanup function */
type CleanupFn = () => void;

/** Component for loading a file from disk */
@Component({
selector: 'cde-file-upload',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule],
templateUrl: './file-upload.component.html',
styleUrl: './file-upload.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FileUploadComponent<T> {
/** Title */
readonly fileTitle = input<string>();
/** Accepted file types */
readonly accept = input.required<string>();
/** File loader */
readonly loader = input.required<FileLoader<T>>();

/** Progress events */
readonly progress = output<number>();
/** Loading start events */
readonly loadStarted = output<File>();
/** Loading cancelled events */
readonly loadCancelled = output<void>();
/** Loading error events */
readonly loadErrored = output<unknown>();
/** Loading completed events */
readonly loadCompleted = output<T>();

/** Reference to injector */
private readonly injector = inject(Injector);

/** Loaded file */
file?: File;
/** Cleanup functions */
private cleanup: CleanupFn[] = [];

/**
* Loads a file
*
* @param el Input element
*/
load(el: HTMLInputElement): void {
if (el.files === null) {
return;
}

const { loader, injector, progress, loadStarted } = this;
const file = (this.file = el.files[0]);
const cleanup: CleanupFn[] = (this.cleanup = []);
const abortController = new AbortController();
const options: FileLoaderOptions = {
signal: abortController.signal,
};
const result = loader()(file, options);
let done = false;

el.files = null;
loadStarted.emit(file);
const progressRef = effect(() => progress.emit(result.progress()), { injector, manualCleanup: true });
this.handleResult(result.result, cleanup, () => done);

cleanup.push(
() => (done = true),
() => progressRef.destroy(),
() => abortController.abort(),
);
}

/**
* Cancels the currently loading file
*/
cancelLoad(): void {
this.runCleanup(this.cleanup);
this.file = undefined;
this.loadCancelled.emit();
}

/**
* Runs a set of cleanup functions
*
* @param fns Cleanup functions to run
*/
private runCleanup(fns: CleanupFn[]): void {
for (const fn of fns) {
fn();
}
}

/**
* Processes the result of a loader
*
* @param result Promise resolving to the loaded data
* @param cleanup Cleanup function to run at the end
* @param isDone Query function returning whether the loading has cancelled
*/
private async handleResult(result: Promise<T>, cleanup: CleanupFn[], isDone: () => boolean): Promise<void> {
const { progress, loadErrored, loadCompleted } = this;
try {
const data = await result;
if (!isDone()) {
progress.emit(1);
loadCompleted.emit(data);
}
} catch (reason) {
if (!isDone()) {
this.file = undefined;
loadErrored.emit(reason);
}
} finally {
this.runCleanup(cleanup);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
}

.disclaimer {
margin: 5rem 6.5rem 8.5rem;
margin: 5rem 6.5rem 8.5rem 6.5rem;
color: hsla(240, 11%, 33%, 1);
span {
display: block;
Expand Down
Loading

0 comments on commit c89d973

Please sign in to comment.