Merge pull request #669 from mz8i/feature/dynamics-boolean-field

Dynamics boolean field
This commit is contained in:
Maciej Ziarkowski 2021-03-19 14:52:20 +00:00 committed by GitHub
commit 52750c85b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 289 additions and 76 deletions

View File

@ -508,6 +508,10 @@
</Rule> </Rule>
</Style> </Style>
<Style name="dynamics_demolished_count"> <Style name="dynamics_demolished_count">
<Rule>
<Filter>[dynamics_has_demolished_buildings] = false</Filter>
<PolygonSymbolizer fill="#0C7BDC" />
</Rule>
<Rule> <Rule>
<Filter>[demolished_buildings_count] &gt;= 7</Filter> <Filter>[demolished_buildings_count] &gt;= 7</Filter>
<PolygonSymbolizer fill="#bd0026" /> <PolygonSymbolizer fill="#bd0026" />

View File

@ -239,6 +239,11 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
verify: false, verify: false,
}, },
dynamics_has_demolished_buildings: {
edit: true,
verify: true
},
demolished_buildings: { demolished_buildings: {
edit: true, edit: true,
verify: false, verify: false,
@ -246,3 +251,6 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
sqlCast: 'jsonb', sqlCast: 'jsonb',
}, },
}); });
export type Building = { [k in keyof typeof dataFieldsConfig]: any };
export type BuildingUpdate = Partial<Building>;

View File

@ -1,6 +1,7 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import { hasAnyOwnProperty } from '../../../helpers'; import { hasAnyOwnProperty } from '../../../helpers';
import { Building, BuildingUpdate } from '../../config/dataFields';
import { getBuildingData } from '../../dataAccess/building'; import { getBuildingData } from '../../dataAccess/building';
import { ArgumentError } from '../../errors/general'; import { ArgumentError } from '../../errors/general';
@ -36,10 +37,30 @@ async function processCurrentLandUseClassifications(buildingId: number, building
/** /**
* Process Dynamics data - sort past buildings by construction date * Process Dynamics data - check field relationships and sort demolished buildings by construction date
*/ */
async function processDynamicsPastBuildings(buildingId: number, buildingUpdate: any): Promise<any> { async function processDynamicsDemolishedBuildings(buildingId: number, buildingUpdate: BuildingUpdate): Promise<BuildingUpdate> {
const currentBuildingData = await getBuildingData(buildingId);
const afterUpdate: Building = Object.assign({}, currentBuildingData, buildingUpdate);
const hasDemolished: boolean = afterUpdate.dynamics_has_demolished_buildings;
const demolishedList: any[] = afterUpdate.demolished_buildings;
if(currentBuildingData.date_year == undefined) {
throw new ArgumentError('Cannot edit demolished buildings data if data on current building age is missing', 'buildingUpdate');
}
if(hasDemolished === false || hasDemolished == undefined) {
if(demolishedList.length > 0) {
throw new ArgumentError('Inconsistent data on whether there were any other buildings on this site', 'buildingUpdate');
}
}
if(buildingUpdate.demolished_buildings != undefined) {
buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min); buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
}
return buildingUpdate; return buildingUpdate;
} }
@ -47,12 +68,12 @@ async function processDynamicsPastBuildings(buildingId: number, buildingUpdate:
/** /**
* Define any custom processing logic for specific building attributes * Define any custom processing logic for specific building attributes
*/ */
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> { export async function processBuildingUpdate(buildingId: number, buildingUpdate: BuildingUpdate): Promise<any> {
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) { if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
} }
if('demolished_buildings' in buildingUpdate) { if(hasAnyOwnProperty(buildingUpdate, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
buildingUpdate = await processDynamicsPastBuildings(buildingId, buildingUpdate); buildingUpdate = await processDynamicsDemolishedBuildings(buildingId, buildingUpdate);
} }
return buildingUpdate; return buildingUpdate;

View File

@ -0,0 +1,100 @@
import React from 'react';
import { BaseDataEntryProps } from '../data-entry';
import { DataTitleCopyable } from '../data-title';
interface ToggleButtonProps {
value: string;
checked: boolean;
disabled: boolean;
onChange: React.ChangeEventHandler<HTMLInputElement>;
checkedClassName: string;
uncheckedClassName: string;
}
const ToggleButton: React.FC<ToggleButtonProps> = ({
value,
checked,
disabled,
onChange,
checkedClassName,
uncheckedClassName,
children
}) => {
return (
/**
* If the toggle button is both checked and disabled, we can't set disabled CSS class
* because then Bootstrap makes the button look like it wasn't checked.
* So we skip the class and force cursor type manually so it doesn't look clickable.
*/
<label className={`btn ${checked ? checkedClassName : uncheckedClassName} ${disabled && !checked && 'disabled'}`}
{...disabled && checked && {
style: { cursor: 'default'}
}}
>
<input type="radio" name="options" value={value + ''}
autoComplete="off"
checked={checked}
onChange={onChange}
disabled={disabled}
/>
{children}
</label>
);
}
interface LogicalDataEntryProps extends BaseDataEntryProps {
value: boolean;
disallowTrue?: boolean;
disallowFalse?: boolean;
disallowNull?: boolean;
}
export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
function handleValueChange(e: React.ChangeEvent<HTMLInputElement>) {
props.onChange?.(props.slug, e.target.value === 'null' ? null : e.target.value === 'true');
}
const isDisabled = props.mode === 'view' || props.disabled;
return (
<>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<div className="btn-group btn-group-toggle">
<ToggleButton
value="true"
checked={props.value === true}
disabled={isDisabled || props.disallowTrue}
checkedClassName='btn-outline-dark active'
uncheckedClassName='btn-outline-dark'
onChange={handleValueChange}
>Yes</ToggleButton>
<ToggleButton
value="null"
checked={props.value == null}
disabled={isDisabled || props.disallowNull}
checkedClassName='btn-secondary active'
uncheckedClassName='btn-outline-dark'
onChange={handleValueChange}
>?</ToggleButton>
<ToggleButton
value="false"
checked={props.value === false}
disabled={isDisabled || props.disallowFalse}
checkedClassName='btn-outline-dark active'
uncheckedClassName='btn-outline-dark'
onChange={handleValueChange}
>No</ToggleButton>
</div>
</>
);
};

View File

@ -6,7 +6,7 @@ import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box'; import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box'; import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers'; import { compareObjects } from '../helpers';
import { Building, UserVerified } from '../models/building'; import { Building, BuildingAttributes, UserVerified } from '../models/building';
import { User } from '../models/user'; import { User } from '../models/user';
import ContainerHeader from './container-header'; import ContainerHeader from './container-header';
@ -72,6 +72,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleVerify = this.handleVerify.bind(this); this.handleVerify = this.handleVerify.bind(this);
this.handleSaveAdd = this.handleSaveAdd.bind(this); this.handleSaveAdd = this.handleSaveAdd.bind(this);
this.handleSaveChange = this.handleSaveChange.bind(this);
this.toggleCopying = this.toggleCopying.bind(this); this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this); this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
@ -190,14 +191,12 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
} }
} }
async handleSubmit(event) { async doSubmit(edits: Partial<BuildingAttributes>) {
event.preventDefault();
this.setState({error: undefined}); this.setState({error: undefined});
try { try {
const data = await apiPost( const data = await apiPost(
`/api/buildings/${this.props.building.building_id}.json`, `/api/buildings/${this.props.building.building_id}.json`,
this.state.buildingEdits edits
); );
if (data.error) { if (data.error) {
@ -210,6 +209,44 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
} }
} }
async handleSubmit(event) {
event.preventDefault();
this.doSubmit(this.state.buildingEdits);
}
async handleSaveAdd(slug: string, newItem: any) {
if(this.props.building[slug] != undefined && !Array.isArray(this.props.building[slug])) {
this.setState({error: 'Unexpected error'});
console.error(`Trying to add a new item to a field (${slug}) which is not an array`);
return;
}
if(this.isEdited()) {
this.setState({error: 'Cannot save a new record when there are unsaved edits to existing records'});
return;
}
const edits = {
[slug]: [...(this.props.building[slug] ?? []), newItem]
};
this.doSubmit(edits);
}
async handleSaveChange(slug: string, value: any) {
if(this.isEdited()) {
this.setState({ error: 'Cannot change this value when there are other unsaved edits. Save or discard the other edits first.'});
return;
}
const edits = {
[slug]: value
};
this.doSubmit(edits);
}
async handleVerify(slug: string, verify: boolean, x: number, y: number) { async handleVerify(slug: string, verify: boolean, x: number, y: number) {
const verifyPatch = {}; const verifyPatch = {};
if (verify) { if (verify) {
@ -242,40 +279,6 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
} }
} }
async handleSaveAdd(slug: string, newItem: any) {
if(this.props.building[slug] != undefined && !Array.isArray(this.props.building[slug])) {
this.setState({error: 'Unexpected error'});
console.error(`Trying to add a new item to a field (${slug}) which is not an array`);
return;
}
if(this.isEdited()) {
this.setState({error: 'Cannot save a new record when there are unsaved edits to existing records'});
return;
}
const edits = {
[slug]: [...(this.props.building[slug] ?? []), newItem]
};
this.setState({error: undefined});
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}.json`,
edits
);
if (data.error) {
this.setState({error: data.error});
} else {
this.props.onBuildingUpdate(this.props.building.building_id, data);
}
} catch(err) {
this.setState({error: err});
}
}
render() { render() {
const currentBuilding = this.getEditedBuilding(); const currentBuilding = this.getEditedBuilding();
@ -356,6 +359,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
onLike={undefined} onLike={undefined}
onVerify={undefined} onVerify={undefined}
onSaveAdd={undefined} onSaveAdd={undefined}
onSaveChange={undefined}
user_verified={[]} user_verified={[]}
/> />
</Fragment> : </Fragment> :
@ -408,6 +412,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
onLike={this.handleLike} onLike={this.handleLike}
onVerify={this.handleVerify} onVerify={this.handleVerify}
onSaveAdd={this.handleSaveAdd} onSaveAdd={this.handleSaveAdd}
onSaveChange={this.handleSaveChange}
user_verified={this.props.user_verified} user_verified={this.props.user_verified}
user={this.props.user} user={this.props.user}
/> />

View File

@ -21,6 +21,9 @@ interface CategoryViewProps {
/* Special handler for adding and immediately saving a new item of an array-like attribute */ /* Special handler for adding and immediately saving a new item of an array-like attribute */
onSaveAdd: (slug: string, newItem: any) => void; onSaveAdd: (slug: string, newItem: any) => void;
/* Special handler for setting a value and immediately saving */
onSaveChange: (slug: string, value: any) => void;
user_verified: any; user_verified: any;
user?: any; user?: any;
} }

View File

@ -11,6 +11,7 @@ import { NumberRangeDataEntry } from './number-range-data-entry';
import './dynamics-data-entry.css'; import './dynamics-data-entry.css';
import { CloseIcon } from '../../../components/icons'; import { CloseIcon } from '../../../components/icons';
import DataTitle, { DataTitleCopyable } from '../../data-components/data-title';
type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]); type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]);
@ -147,6 +148,7 @@ const DynamicsDataRow: React.FC<DynamicsDataRowProps> = ({
}; };
interface DynamicsDataEntryProps extends BaseDataEntryProps { interface DynamicsDataEntryProps extends BaseDataEntryProps {
title: string;
value: DemolishedBuilding[]; value: DemolishedBuilding[];
editableEntries: boolean; editableEntries: boolean;
maxYear: number; maxYear: number;
@ -206,11 +208,13 @@ export const DynamicsDataEntry: React.FC<DynamicsDataEntryProps> = (props) => {
<> <>
<div> <div>
{ {
isEditing && isEditing ?
<> <>
<h6 className="h6">Existing records for demolished buildings</h6> <h6 className="h6">Existing records for demolished buildings</h6>
<label>Please supply sources for any edits of existing records</label> <label>Please supply sources for any edits of existing records</label>
</> </> :
<DataTitleCopyable slug={props.slug} title={props.title} tooltip={null}/>
} }
<ul className="data-entry-list"> <ul className="data-entry-list">
{ {

View File

@ -13,6 +13,7 @@ import NumericDataEntry from '../../data-components/numeric-data-entry';
import withCopyEdit from '../../data-container'; import withCopyEdit from '../../data-container';
import { CategoryViewProps } from '../category-view-props'; import { CategoryViewProps } from '../category-view-props';
import { LogicalDataEntry } from '../../data-components/logical-data-entry/logical-data-entry';
/** /**
* Dynamics view/edit section * Dynamics view/edit section
@ -60,7 +61,23 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
</FieldRow> </FieldRow>
</DynamicsBuildingPane> </DynamicsBuildingPane>
{ {
currentBuildingConstructionYear != undefined ? currentBuildingConstructionYear == undefined ?
<InfoBox>To add historical records, fill in the <Link to={ageLinkUrl}>Age</Link> data first.</InfoBox> :
<>
<LogicalDataEntry
slug='dynamics_has_demolished_buildings'
title={dataFields.dynamics_has_demolished_buildings.title}
value={building.dynamics_has_demolished_buildings}
disallowFalse={(building.demolished_buildings?.length ?? 0) > 0}
disallowNull={(building.demolished_buildings?.length ?? 0) > 0}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
{
building.dynamics_has_demolished_buildings &&
<> <>
<DynamicsDataEntry <DynamicsDataEntry
@ -73,7 +90,7 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
value={building.demolished_buildings} value={building.demolished_buildings}
editableEntries={true} editableEntries={true}
slug='demolished_buildings' slug='demolished_buildings'
title={undefined} title={dataFields.demolished_buildings.title}
mode={props.mode} mode={props.mode}
onChange={props.onChange} onChange={props.onChange}
onSaveAdd={props.onSaveAdd} onSaveAdd={props.onSaveAdd}
@ -85,10 +102,10 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
props.mode === 'view' && props.mode === 'view' &&
<InfoBox>Switch to edit mode to add/edit past building records</InfoBox> <InfoBox>Switch to edit mode to add/edit past building records</InfoBox>
} }
</> : </>
<InfoBox>To add historical records, fill in the <Link to={ageLinkUrl}>Age</Link> data first.</InfoBox> }
</>
} }
</DataEntryGroup> </DataEntryGroup>
<DataEntryGroup name="Future planned data collection" collapsed={false} showCount={false}> <DataEntryGroup name="Future planned data collection" collapsed={false} showCount={false}>

View File

@ -188,7 +188,7 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
mapStyle: 'dynamics_demolished_count', mapStyle: 'dynamics_demolished_count',
legend: { legend: {
title: 'Dynamics', title: 'Dynamics',
description: 'Past (demolished) buildings on site', description: 'Demolished buildings on the same site',
elements: [ elements: [
{ {
text: '7+', text: '7+',
@ -211,6 +211,9 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
}, { }, {
text: '1', text: '1',
color: '#ffe8a9', color: '#ffe8a9',
}, {
text: 'None',
color: '#0C7BDC'
} }
], ],
}, },

View File

@ -7,11 +7,44 @@ import { Category } from './categories-config';
* e.g. dataFields.foo_bar would not be highlighted as an error. * e.g. dataFields.foo_bar would not be highlighted as an error.
*/ */
export interface DataFieldDefinition { export interface DataFieldDefinition {
/**
* The primary category to which the field belongs.
*
* A field could be displayed in several categories, but this value will be used
* when a single category needs to be shown in the context of a field, e.g.
* in the case of edit history or the copy-paste tool (multi-edit)
* */
category: Category; category: Category;
/**
* The human-readable title of the field to be displayed as label.
*/
title: string; title: string;
/**
* Text to be displayed in the hint tooltip next to the input field.
*
* This supports a simple Markdown-like syntax for embedding clickable URLs in the hint
* for example "see [here](http://example.com/info.html) for more information"
*/
tooltip?: string; tooltip?: string;
properties?: { [key: string]: DataFieldDefinition};
example: any; // the example field is used to automatically determine the type of the properties in the Building interface /**
* If the defined type is an array, this describes the fields of each array item.
* The nested fields don't currently need a category field to be defined.
*/
items?: { [key: string]: Omit<DataFieldDefinition, 'category'> };
/**
* The example is used to determine the runtime type in which the attribute data is stored (e.g. number, string, object)
* This gives the programmer auto-complete of all available building attributes when implementing a category view.
*
* E.g. if the field is a text value, an empty string ("") is the simplest example.
*
* Making it semantically correct is useful but not necessary.
* E.g. for building attachment form, you could use "Detached" as example
*/
example: any;
} }
export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
@ -412,6 +445,12 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
example: 100, example: 100,
}, },
dynamics_has_demolished_buildings: {
category: Category.Dynamics,
title: 'Where any other buildings ever built on this site?',
example: true,
},
demolished_buildings: { demolished_buildings: {
category: Category.Dynamics, category: Category.Dynamics,
title: 'Past (demolished) buildings on this site', title: 'Past (demolished) buildings on this site',

View File

@ -24,7 +24,7 @@ export function parseJsonOrDefault(jsonString: string) {
} }
} }
export function hasAnyOwnProperty(obj: {}, keys: string[]) { export function hasAnyOwnProperty<T>(obj: T, keys: (keyof T)[]) {
return keys.some(k => obj.hasOwnProperty(k)); return keys.some(k => obj.hasOwnProperty(k));
} }

View File

@ -136,10 +136,11 @@ const LAYER_QUERIES = {
dynamics_demolished_count: ` dynamics_demolished_count: `
SELECT SELECT
geometry_id, geometry_id,
jsonb_array_length(demolished_buildings) as demolished_buildings_count jsonb_array_length(demolished_buildings) as demolished_buildings_count,
dynamics_has_demolished_buildings
FROM FROM
buildings buildings
WHERE jsonb_array_length(demolished_buildings) > 0`, WHERE jsonb_array_length(demolished_buildings) > 0 OR dynamics_has_demolished_buildings = FALSE`,
}; };
const GEOMETRY_FIELD = 'geometry_geom'; const GEOMETRY_FIELD = 'geometry_geom';

View File

@ -0,0 +1 @@
ALTER TABLE buildings DROP COLUMN IF EXISTS dynamics_has_demolished_buildings;

View File

@ -0,0 +1,7 @@
ALTER TABLE buildings
ADD COLUMN dynamics_has_demolished_buildings BOOLEAN;
UPDATE buildings
SET dynamics_has_demolished_buildings = TRUE
WHERE jsonb_array_length(demolished_buildings) > 0;