Skip to content

Commit 197f24b

Browse files
authored
Add CSV export for draft jobs, mean, max, arbitrary range (#3564)
1 parent a5c3c39 commit 197f24b

21 files changed

+1365
-272
lines changed

.github/copilot-instructions.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ This repository contains three interconnected applications:
2626
- Error handling with ErrorReportingService
2727
- Keep related files together in feature folders
2828
- Follow existing naming conventions
29+
- Follow MVVM design, where domain objects and business logic are in Models, templates represent information to the user in Views, and ViewModels transform and bridge data between Models and Views.
30+
- Component templates should be in separate .html files, rather than specified inline in the component decorator.
31+
- Component template stylesheets should be in separate .scss files, rather than specified inline in the component decorator.
2932

3033
# Frontend localization
3134

@@ -35,11 +38,11 @@ This repository contains three interconnected applications:
3538
- Localizations that a Community Checker user might see should be created or edited in src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json. Only localizations that a Community Checker user will not see can be created or edited in src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json.
3639
- Even if something is a system-wide feature that isn't specific to community checking functionality, it should still be placed in checking_en.json if a community checking user would POSSIBLY see it.
3740

38-
# Testing
41+
# Frontend testing
3942

4043
- Write unit tests for new components and services
4144
- Follow existing patterns for mocking dependencies
42-
- Use TestEnvironment pattern from existing tests
45+
- Use TestEnvironment pattern from existing tests. Use the TestEnvironment class pattern rather than using a `beforeEach`.
4346
- Test both online and offline scenarios
4447
- Test permission boundaries
4548

@@ -56,15 +59,21 @@ This repository contains three interconnected applications:
5659
# TypeScript language
5760

5861
- Use explicit true/false/null/undefined rather than truthy/falsy
59-
- Never rely on JavaScript's truthy or falsy. Instead, work with actual true, false, null, and undefined values, rather than relying on their interpretation as truthy or falsy. For example, if `count` might be null, or undefined, or zero, don't write code like `if (count)` or `const foo:string = someVariable ? 'a' : 'b'`. Instead, inspect for the null, undefined, or zero rather than letting the value be interpreted as truthy for falsy. For example, use`if (count == null)` or `const foo:string = someVariable != null 'a' : 'b'` or `if (count > 0)`.
62+
- Never rely on JavaScript's truthy or falsy. Instead, work with actual true, false, null, and undefined values, rather than relying on their interpretation as truthy or falsy. For example, if `count` might be null, or undefined, or zero, don't write code like `if (count)` or `const foo:string = someVariable ? 'a' : 'b'`. Instead, inspect for the null, undefined, or zero rather than letting the value be interpreted as truthy for falsy. For example, use `if (count == null)` or `const foo:string = someVariable != null 'a' : 'b'` or `if (count > 0)`.
6063
- Specify types where not obvious, such as when declaring variables and arguments, and for function return types.
6164
- Use `@if {}` syntax rather than `*ngIf` syntax.
65+
- Although interacting with existing code and APIs may necessitate the use of `null`, when writing new code, prefer using `undefined` rather than `null`.
66+
- Fields that are of type Subject or BehaviorSubject should have names that end with a `$`.
6267

6368
# Code
6469

6570
- All code that you write should be able to pass eslint linting tests for TypeScript, or csharpier for C#.
6671
- Don't merely write code for the local context, but make changes that are good overall considering the architecture of the application and structure of the files and classes.
72+
- It is better to write code that is verbose and understandable than terse and concise.
73+
- It is better to explicitly check for and handle problems, or prevent problems from happening, than to assume problems will not happen.
74+
- Corner-cases happen. They should be handled in code.
6775

6876
# Running commands
6977

7078
- If you run frontend tests, run them in the `src/SIL.XForge.Scripture/ClientApp` directory with a command such as `npm run test:headless -- --watch=false --include '**/text.component.spec.ts' --include '**/settings.component.spec.ts'`
79+
- If you need to run all frontend tests, you can run them in the `src/SIL.XForge.Scripture/ClientApp` directory with command `npm run test:headless -- --watch=false`

src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,18 +360,18 @@ export class SFProjectService extends ProjectService<SFProject, SFProjectDoc> {
360360
async onlineAllEventMetricsForConstructingDraftJobs(
361361
eventTypes: string[],
362362
projectId?: string,
363-
daysBack?: number
363+
startDate?: Date,
364+
endDate?: Date
364365
): Promise<QueryResults<EventMetric> | undefined> {
365366
const params: any = {
366367
projectId: projectId ?? null,
367368
scopes: [3], // Drafting scope
368369
eventTypes
369370
};
370371

371-
if (daysBack != null) {
372-
const fromDate = new Date();
373-
fromDate.setDate(fromDate.getDate() - daysBack);
374-
params.fromDate = fromDate.toISOString();
372+
if (startDate != null && endDate != null) {
373+
params.fromDate = startDate.toISOString();
374+
params.toDate = endDate.toISOString();
375375
}
376376

377377
return await this.onlineInvoke<QueryResults<EventMetric>>('eventMetrics', params);

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_draft-jobs-theme.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
--sf-draft-jobs-project-link-color: #{mat.get-theme-color($theme, primary, if($is-dark, 70, 40))};
88
--sf-draft-jobs-book-count-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 70, 40))};
99
--sf-draft-jobs-disabled-link-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 50, 50))};
10+
--sf-draft-jobs-statistics-background: #{mat.get-theme-color($theme, neutral-variant, if($is-dark, 30, 90))};
11+
--sf-draft-jobs-statistics-text: #{mat.get-theme-color($theme, on-surface)};
1012
}
1113

1214
@mixin theme($theme) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<mat-form-field class="date-range-field">
2+
<mat-label>Date range</mat-label>
3+
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="dateRangePicker" [dateFilter]="disableFutureDates">
4+
<input matStartDate matInput formControlName="start" [max]="maxSelectableDate" placeholder="Start date" />
5+
<input matEndDate matInput formControlName="end" [max]="maxSelectableDate" placeholder="End date" />
6+
</mat-date-range-input>
7+
<mat-datepicker-toggle matIconSuffix [for]="dateRangePicker"></mat-datepicker-toggle>
8+
<mat-date-range-picker #dateRangePicker></mat-date-range-picker>
9+
@if (dateRangeFormatHint != null) {
10+
<mat-hint align="start" class="date-format-hint">{{ dateRangeFormatHint }}</mat-hint>
11+
}
12+
@if (dateRangeForm.get("start")?.hasError("futureDate")) {
13+
<mat-error>Start date cannot be in the future.</mat-error>
14+
}
15+
@if (dateRangeForm.get("end")?.hasError("futureDate")) {
16+
<mat-error>End date cannot be in the future.</mat-error>
17+
}
18+
@if (dateRangeForm.invalid) {
19+
<mat-error>Select a valid date range ending no later than today.</mat-error>
20+
}
21+
</mat-form-field>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.date-range-field {
2+
min-width: 260px;
3+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
3+
import { DateAdapter, provideNativeDateAdapter } from '@angular/material/core';
4+
import { MatDatepickerModule } from '@angular/material/datepicker';
5+
import { MatFormFieldModule } from '@angular/material/form-field';
6+
import { MatInputModule } from '@angular/material/input';
7+
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
8+
import { BehaviorSubject } from 'rxjs';
9+
import { I18nService } from 'xforge-common/i18n.service';
10+
import { Locale } from 'xforge-common/models/i18n-locale';
11+
import { DateRangePickerComponent } from './date-range-picker.component';
12+
13+
describe('DateRangePickerComponent', () => {
14+
it('should initialize with default date range', () => {
15+
const env = new TestEnvironment();
16+
env.fixture.detectChanges();
17+
const formValue = env.component.dateRangeForm.value;
18+
expect(formValue.start).toBeTruthy();
19+
expect(formValue.end).toBeTruthy();
20+
expect(formValue.start!.getTime()).toBeLessThan(formValue.end!.getTime());
21+
});
22+
23+
it('should disable future dates', () => {
24+
const env = new TestEnvironment();
25+
const today = new Date();
26+
const tomorrow = new Date(today);
27+
tomorrow.setDate(today.getDate() + 1);
28+
29+
expect(env.component.disableFutureDates(today)).toBe(true);
30+
expect(env.component.disableFutureDates(tomorrow)).toBe(false);
31+
});
32+
33+
it('should show format hint for locale', () => {
34+
const env = new TestEnvironment();
35+
env.fixture.detectChanges();
36+
expect(env.component.dateRangeFormatHint).toBeDefined();
37+
});
38+
39+
describe('date adapter localization', () => {
40+
it('should mirror i18n locale changes in the date adapter', () => {
41+
const env = new TestEnvironment();
42+
const dateAdapter = TestBed.inject(DateAdapter);
43+
spyOn(dateAdapter, 'setLocale');
44+
45+
const updatedLocale: Locale = {
46+
canonicalTag: 'fr',
47+
direction: 'ltr',
48+
englishName: 'French',
49+
localName: 'Français',
50+
production: true,
51+
tags: ['fr']
52+
};
53+
54+
env.localeSubject.next(updatedLocale);
55+
56+
expect(dateAdapter.setLocale).toHaveBeenCalledWith('fr');
57+
});
58+
});
59+
});
60+
61+
class TestEnvironment {
62+
readonly localeSubject: BehaviorSubject<Locale>;
63+
readonly i18nStub: Partial<I18nService>;
64+
readonly fixture: ComponentFixture<DateRangePickerComponent>;
65+
readonly component: DateRangePickerComponent;
66+
67+
constructor() {
68+
const initialLocale: Locale = {
69+
canonicalTag: 'en-US',
70+
direction: 'ltr',
71+
englishName: 'English',
72+
localName: 'English',
73+
production: true,
74+
tags: ['en-US']
75+
};
76+
77+
this.localeSubject = new BehaviorSubject<Locale>(initialLocale);
78+
79+
this.i18nStub = {
80+
localeCode: 'en-US',
81+
locale$: this.localeSubject.asObservable()
82+
};
83+
84+
TestBed.configureTestingModule({
85+
imports: [
86+
DateRangePickerComponent,
87+
ReactiveFormsModule,
88+
MatFormFieldModule,
89+
MatInputModule,
90+
MatDatepickerModule,
91+
NoopAnimationsModule
92+
],
93+
providers: [provideNativeDateAdapter(), FormBuilder, { provide: I18nService, useValue: this.i18nStub }]
94+
}).compileComponents();
95+
96+
this.fixture = TestBed.createComponent(DateRangePickerComponent);
97+
this.component = this.fixture.componentInstance;
98+
this.fixture.detectChanges();
99+
}
100+
}

0 commit comments

Comments
 (0)