diff --git a/nad_ch/application/view_models.py b/nad_ch/application/view_models.py index b063507..3fb036a 100644 --- a/nad_ch/application/view_models.py +++ b/nad_ch/application/view_models.py @@ -104,8 +104,7 @@ class DataSubmissionViewModel(ViewModel): def create_data_submission_vm(submission: DataSubmission) -> DataSubmissionViewModel: report_json = [] if submission.report is not None: - enriched_report = enrich_report(submission.report) - report_json = json.dumps(enriched_report) + report_json = enrich_report(submission.report) return DataSubmissionViewModel( id=submission.id, diff --git a/nad_ch/controllers/web/routes/data_submissions.py b/nad_ch/controllers/web/routes/data_submissions.py index 95a79b0..90676d3 100644 --- a/nad_ch/controllers/web/routes/data_submissions.py +++ b/nad_ch/controllers/web/routes/data_submissions.py @@ -1,4 +1,4 @@ -from flask import Blueprint, current_app, render_template, g +from flask import Blueprint, current_app, render_template, g, jsonify from flask_login import login_required, current_user from nad_ch.application.use_cases.data_submissions import ( get_data_submission, @@ -39,3 +39,10 @@ def reports(): def view_report(submission_id): view_model = get_data_submission(g.ctx, submission_id) return render_template("data_submissions/show.html", submission=view_model) + + +@submissions_bp.route("/api/reports/") +@login_required +def view_report_json(submission_id): + view_model = get_data_submission(g.ctx, submission_id) + return jsonify(view_model.report) diff --git a/nad_ch/controllers/web/sass/components/_usa-button.scss b/nad_ch/controllers/web/sass/components/_usa-button.scss index e4c047f..5378b21 100644 --- a/nad_ch/controllers/web/sass/components/_usa-button.scss +++ b/nad_ch/controllers/web/sass/components/_usa-button.scss @@ -8,3 +8,63 @@ width: auto; height: 30px; } + +@mixin rounded-border { + border-radius: 99px; +} + +@mixin focus-style { + // Override for outline-offset when focused and not disabled + &:not([disabled]):focus, + &:not([disabled]).usa-focus { + outline: 0; + } +} + +.usa-button-group-toggle { + justify-content: space-between; +} + +.usa-button-toggle-on, +.usa-button-toggle-off { + @include rounded-border; + @include focus-style; + color: var(--Primary-primary-dark, #1a4480); + display: flex; + justify-content: center; + align-items: center; + min-height: 50px; + min-width: 200px; +} + +.usa-button-toggle-on { + background: var(--Blue-vivid-blue-vivid-5v, #e8f5ff); + + &:hover { + background: var(--Blue-vivid-blue-vivid-10v, #cfe8ff); + color: var(--Primary-primary-dark, #1a4480); + } + + &:active { + background: var(--Primary-primary-dark, #1a4480); + color: var(--Base-base-lightest, #f0f0f0); + } +} + +.usa-button-toggle-off { + background: var(--Base-base-lightest, #f0f0f0); + border: 2px solid var(--Primary-primary-dark, #1a4480); + border-radius: 99px; + color: var(--Primary-primary-dark, #1a4480); + + &:hover { + border: 2px solid var(--Primary-primary-dark, #1a4480); + background: var(--Blue-vivid-blue-vivid-10v, #cfe8ff); + color: var(--Primary-primary-dark, #1a4480); + } + + &:active { + color: var(--Base-base-lightest, #f0f0f0); + background: var(--Primary-primary-dark, #1a4480); + } +} diff --git a/nad_ch/controllers/web/sass/components/_usa-tag.scss b/nad_ch/controllers/web/sass/components/_usa-tag.scss new file mode 100644 index 0000000..af5ef37 --- /dev/null +++ b/nad_ch/controllers/web/sass/components/_usa-tag.scss @@ -0,0 +1,38 @@ +.usa-tag__success { + border-radius: 4px; + border: 1px solid var(--Green-cool-vivid-green-cool-vivid-10v, #b7f5bd); + background: var(--Green-cool-vivid-green-cool-vivid-10v, #b7f5bd); + color: black; + display: inline-flex; + padding: 2px 8px; + align-items: center; +} + +.usa-tag__info { + border-radius: 4px; + background: var(--Blue-vivid-blue-vivid-10v, #cfe8ff); + color: black; + display: inline-flex; + padding: 2px 8px; + align-items: center; +} + +.usa-tag__warning { + border-radius: 4px; + border: 1px solid var(--Warning-warning, #ffbe2e); + background: var(--Warning-warning, #ffbe2e); + color: black; + display: inline-flex; + padding: 2px 8px; + align-items: center; +} + +.usa-tag__error { + border-radius: 4px; + border: 1px solid var(--Error-error-dark, #b50909); + background: var(--Error-error-dark, #b50909); + color: white; + display: inline-flex; + padding: 2px 8px; + align-items: center; +} diff --git a/nad_ch/controllers/web/sass/index.scss b/nad_ch/controllers/web/sass/index.scss index 93525e7..06b9995 100644 --- a/nad_ch/controllers/web/sass/index.scss +++ b/nad_ch/controllers/web/sass/index.scss @@ -9,3 +9,4 @@ @import 'components/usa-select'; @import 'components/usa-sidenav'; @import 'components/usa-table'; +@import 'components/usa-tag'; diff --git a/nad_ch/controllers/web/src/components/CompletenessReport.spec.ts b/nad_ch/controllers/web/src/components/CompletenessReport.spec.ts new file mode 100644 index 0000000..7909a5c --- /dev/null +++ b/nad_ch/controllers/web/src/components/CompletenessReport.spec.ts @@ -0,0 +1,120 @@ +/** + * @jest-environment jsdom + */ + +import { + CompletenessReport, + CompletenessReportComponent, +} from './CompletenessReport'; +import { AlpineComponent } from 'alpinejs'; +import { fetchReportData } from '../services'; + +jest.mock('../services', () => ({ + fetchReportData: jest.fn(), +})); + +describe('CompletenessReportComponent', () => { + let component: AlpineComponent; + const mockReportData = { + features: [ + { + provided_feature_name: 'AddNum_Pre', + nad_feature_name: 'AddNum_Pre', + populated_count: 1141, + null_count: 0, + required: false, + status: 'No error', + populated_percentage: '100%', + null_percentage: '0%', + }, + { + provided_feature_name: 'Add_Number', + nad_feature_name: 'Add_Number', + populated_count: 1141, + null_count: 0, + required: true, + status: 'No error', + populated_percentage: '100%', + null_percentage: '0%', + }, + ], + overview: { + data_update_required: false, + etl_update_required: false, + feature_count: 10, + features_flagged: 2, + }, + }; + + beforeEach(() => { + (fetchReportData as jest.Mock).mockResolvedValue(mockReportData); + component = CompletenessReport('1'); + }); + + it('initializes with default data', () => { + expect(component.id).toBe(1); + expect(component.isLoading).toBe(true); + expect(component.groupedFeatures).toEqual([]); + expect(component.isGroupedByStatus).toBe(false); + }); + + it('loads report data on init', async () => { + await component.init(); + expect(fetchReportData).toHaveBeenCalledWith(1); + expect(component.isLoading).toBe(false); + expect(component.report).toEqual(mockReportData); + }); + + it('groups features by status', () => { + component.report = mockReportData; + const groupedFeatures = component.groupFeatures(mockReportData.features); + expect(groupedFeatures).toEqual([ + { + status: 'No error', + features: [ + { + provided_feature_name: 'AddNum_Pre', + nad_feature_name: 'AddNum_Pre', + populated_count: 1141, + null_count: 0, + required: false, + status: 'No error', + populated_percentage: '100%', + null_percentage: '0%', + }, + { + provided_feature_name: 'Add_Number', + nad_feature_name: 'Add_Number', + populated_count: 1141, + null_count: 0, + required: true, + status: 'No error', + populated_percentage: '100%', + null_percentage: '0%', + }, + ], + }, + ]); + }); + + it('computes buttonText based on isGroupedByStatus', () => { + expect(component.buttonText).toBe('Group by Status'); + component.isGroupedByStatus = true; + expect(component.buttonText).toBe('Ungroup by Status'); + }); + + it('computes toggleButtonClass based on isGroupedByStatus', () => { + expect(component.toggleButtonClass).toBe('usa-button-toggle-off'); + component.isGroupedByStatus = true; + expect(component.toggleButtonClass).toBe('usa-button-toggle-on'); + }); + + it('gets status tag class based on different statuses', () => { + expect(component.getStatusTagClass('No error')).toBe('usa-tag__success'); + expect(component.getStatusTagClass('Updated by calculation')).toBe( + 'usa-tag__warning', + ); + expect(component.getStatusTagClass('Rejected')).toBe('usa-tag__error'); + expect(component.getStatusTagClass('Other')).toBe('usa-tag__info'); + }); +}); diff --git a/nad_ch/controllers/web/src/components/CompletenessReport.ts b/nad_ch/controllers/web/src/components/CompletenessReport.ts new file mode 100644 index 0000000..c286172 --- /dev/null +++ b/nad_ch/controllers/web/src/components/CompletenessReport.ts @@ -0,0 +1,112 @@ +import { AlpineComponent } from 'alpinejs'; +import { fetchReportData } from '../services'; + +export type CompletenessReport = { + features: Feature[]; + overview: Overview; +}; + +type Overview = { + data_update_required: boolean; + etl_update_required: boolean; + feature_count: number; + features_flagged: number; +}; + +type Feature = { + provided_feature_name: string; + nad_feature_name: string; + populated_count: number; + null_count: number; + required: boolean; + status: string; + populated_percentage: string; + null_percentage: string; +}; + +type GroupedFeature = { + status: string; + features: Feature[]; +}; + +export interface CompletenessReportComponent { + id: number; + isLoading: boolean; + report: CompletenessReport | null; + groupedFeatures: GroupedFeature[]; + isGroupedByStatus: boolean; + buttonText: string; + toggleButtonClass: string; + init(): Promise; + groupFeatures(features: Feature[]): GroupedFeature[]; + getStatusTagClass(status: string): string; +} + +export function CompletenessReport( + id: string, +): AlpineComponent { + return { + id: parseInt(id), + isLoading: true, + report: { + features: [], + overview: { + data_update_required: false, + etl_update_required: false, + feature_count: 0, + features_flagged: 0, + }, + }, + groupedFeatures: [], + isGroupedByStatus: false, + async init(): Promise { + try { + this.report = await fetchReportData(this.id); + if (this.report) { + this.groupedFeatures = this.groupFeatures(this.report.features); + } + } catch (error) { + console.error('Failed to load report:', error); + } finally { + this.isLoading = false; + } + }, + groupFeatures(features: Feature[]): GroupedFeature[] { + const groupedByStatus = features.reduce>( + (acc, obj) => { + if (!acc[obj.status]) { + acc[obj.status] = []; + } + acc[obj.status].push(obj); + return acc; + }, + {}, + ); + + return Object.keys(groupedByStatus).map((status) => ({ + status: status, + features: groupedByStatus[status], + })); + }, + get buttonText(): string { + return this.isGroupedByStatus ? 'Ungroup by Status' : 'Group by Status'; + }, + get toggleButtonClass(): string { + return this.isGroupedByStatus + ? 'usa-button-toggle-on' + : 'usa-button-toggle-off'; + }, + getStatusTagClass(status: string): string { + switch (status) { + case 'No error': + return 'usa-tag__success'; + case 'Updated by calculation': + return 'usa-tag__warning'; + case 'Rejected': + return 'usa-tag__error'; + default: + return 'usa-tag__info'; + } + }, + }; +} diff --git a/nad_ch/controllers/web/src/index.ts b/nad_ch/controllers/web/src/index.ts index 3f40917..c2210df 100644 --- a/nad_ch/controllers/web/src/index.ts +++ b/nad_ch/controllers/web/src/index.ts @@ -2,6 +2,7 @@ import '@uswds/uswds/css/uswds.css'; import '@uswds/uswds'; import Alpine from 'alpinejs'; import { MappingForm } from './components/MappingForm'; +import { CompletenessReport } from './components/CompletenessReport'; declare global { interface Window { @@ -12,6 +13,7 @@ declare global { document.addEventListener('alpine:init', () => { Alpine.data('MappingForm', MappingForm); + Alpine.data('CompletenessReport', CompletenessReport); }); window.Alpine = Alpine; diff --git a/nad_ch/controllers/web/src/services.ts b/nad_ch/controllers/web/src/services.ts new file mode 100644 index 0000000..8a285a9 --- /dev/null +++ b/nad_ch/controllers/web/src/services.ts @@ -0,0 +1,8 @@ +import { BASE_URL } from './config'; +import { CompletenessReport } from './components/CompletenessReport'; + +export async function fetchReportData(id: number): Promise { + const response = await fetch(`${BASE_URL}/api/reports/${id}`); + const reportData = await response.json(); + return reportData; +} diff --git a/nad_ch/controllers/web/templates/data_submissions/show.html b/nad_ch/controllers/web/templates/data_submissions/show.html index a0d073b..e4b3124 100644 --- a/nad_ch/controllers/web/templates/data_submissions/show.html +++ b/nad_ch/controllers/web/templates/data_submissions/show.html @@ -18,7 +18,6 @@ type="button" class="usa-accordion__button usa-nav__link" aria-expanded="false" - aria-controls="extended-mega-nav-section-one" :class="showSummary ? 'usa-current' : ''" @click="showSummary = true; showCompleteness = false" > @@ -31,7 +30,6 @@ type="button" class="usa-accordion__button usa-nav__link" aria-expanded="false" - aria-controls="extended-mega-nav-section-two" :class="showCompleteness ? 'usa-current' : ''" @click="showSummary = false; showCompleteness = true" > @@ -56,25 +54,25 @@
-

1141

+

{{ submission.report.overview.feature_count }}

Total records submitted

-

0

+

{{ submission.report.overview.feature_count }}

Records added to NAD

-

537

+

{{ submission.report.overview.features_flagged }}

Records flagged

-

Yes

+

{{ submission.report.overview.etl_update_required }}

ETL update required

-

Yes

+

{{ submission.report.overview.data_update_required }}

Data update required

@@ -88,7 +86,153 @@

Yes

- {# Table goes here #} +
+
+ +
+ +
+ + + + + + + + + + + +
Submitted Field + + * + + NAD Field + StatusEmptyPopulated
+
+ +
+
+ + + + + + + + + + + + + +
Submitted Field + + * + + NAD Field + StatusEmptyPopulated
+
+
+
{% else %}

No such submission exists.

diff --git a/scripts/seed.py b/scripts/seed.py index 4e47d05..76de947 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -61,7 +61,7 @@ def main(): "LandmkName": "", "County": "county", "Inc_Muni": "city", - "Post_City": "", + "Post_City": "post_city", "Census_Plc": "", "Uninc_Comm": "", "Nbrhd_Comm": "", @@ -71,16 +71,16 @@ def main(): "PlaceOther": "", "PlaceNmTyp": "", "State": "state", - "Zip_Code": "", + "Zip_Code": "zip", "Plus_4": "", "UUID": "guid", - "AddAuth": "", + "AddAuth": "auth", "AddrRefSys": "", "Longitude": "long", "Latitude": "lat", "NatGrid": "nat_grid", "Elevation": "", - "Placement": "", + "Placement": "placement", "AddrPoint": "address_point", "Related_ID": "", "RelateType": "", @@ -112,7 +112,161 @@ def main(): filename = DataSubmission.generate_filename(zipped_gdb_path, saved_producer) ctx.storage.upload(zipped_gdb_path, filename) - new_submission = DataSubmission(filename, saved_producer, saved_column_map) + report = { + "overview": { + "feature_count": 1141, + "features_flagged": 0, + "etl_update_required": False, + "data_update_required": False, + }, + "features": [ + { + "provided_feature_name": "AddNum_Pre", + "nad_feature_name": "AddNum_Pre", + "populated_count": 1141, + "null_count": 0, + "required": False, + "status": "No error", + }, + { + "provided_feature_name": "Add_Number", + "nad_feature_name": "Add_Number", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "AddNum_Suf", + "nad_feature_name": "AddNum_Suf", + "populated_count": 1141, + "null_count": 0, + "required": False, + "status": "No error", + }, + { + "provided_feature_name": "AddNo_Full", + "nad_feature_name": "AddNo_Full", + "populated_count": 1136, + "null_count": 5, + "required": True, + "status": "Rejected", + }, + { + "provided_feature_name": "St_PreMod", + "nad_feature_name": "St_PreMod", + "populated_count": 604, + "null_count": 537, + "required": False, + "status": "Custom ETL needed", + }, + { + "provided_feature_name": "St_Name", + "nad_feature_name": "St_Name", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "StNam_Full", + "nad_feature_name": "StNam_Full", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "County", + "nad_feature_name": "County", + "populated_count": 1140, + "null_count": 1, + "required": True, + "status": "Rejected", + }, + { + "provided_feature_name": "Inc_Muni", + "nad_feature_name": "Inc_Muni", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "State", + "nad_feature_name": "State", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "UUID", + "nad_feature_name": "UUID", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "Longitude", + "nad_feature_name": "Longitude", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "Latitude", + "nad_feature_name": "Latitude", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "NatGrid", + "nad_feature_name": "NatGrid", + "populated_count": 1130, + "null_count": 11, + "required": True, + "status": "Updated by calculation", + }, + { + "provided_feature_name": "AddrPoint", + "nad_feature_name": "AddrPoint", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "DateUpdate", + "nad_feature_name": "DateUpdate", + "populated_count": 1141, + "null_count": 0, + "required": True, + "status": "No error", + }, + { + "provided_feature_name": "NAD_Source", + "nad_feature_name": "NAD_Source", + "populated_count": 0, + "null_count": 1141, + "required": True, + "status": "Rejected", + }, + { + "provided_feature_name": "DataSet_ID", + "nad_feature_name": "DataSet_ID", + "populated_count": 0, + "null_count": 1141, + "required": True, + "status": "Rejected", + }, + ], + } + new_submission = DataSubmission(filename, saved_producer, saved_column_map, report) ctx.submissions.add(new_submission) os.remove(zipped_gdb_path)