Merge pull request #669 from mz8i/feature/dynamics-boolean-field
Dynamics boolean field
This commit is contained in:
commit
52750c85b6
@ -508,6 +508,10 @@
|
||||
</Rule>
|
||||
</Style>
|
||||
<Style name="dynamics_demolished_count">
|
||||
<Rule>
|
||||
<Filter>[dynamics_has_demolished_buildings] = false</Filter>
|
||||
<PolygonSymbolizer fill="#0C7BDC" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[demolished_buildings_count] >= 7</Filter>
|
||||
<PolygonSymbolizer fill="#bd0026" />
|
||||
|
@ -239,6 +239,11 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
|
||||
verify: false,
|
||||
},
|
||||
|
||||
dynamics_has_demolished_buildings: {
|
||||
edit: true,
|
||||
verify: true
|
||||
},
|
||||
|
||||
demolished_buildings: {
|
||||
edit: true,
|
||||
verify: false,
|
||||
@ -246,3 +251,6 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
|
||||
sqlCast: 'jsonb',
|
||||
},
|
||||
});
|
||||
|
||||
export type Building = { [k in keyof typeof dataFieldsConfig]: any };
|
||||
export type BuildingUpdate = Partial<Building>;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { hasAnyOwnProperty } from '../../../helpers';
|
||||
import { Building, BuildingUpdate } from '../../config/dataFields';
|
||||
import { getBuildingData } from '../../dataAccess/building';
|
||||
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);
|
||||
}
|
||||
|
||||
return buildingUpdate;
|
||||
}
|
||||
|
||||
@ -47,12 +68,12 @@ async function processDynamicsPastBuildings(buildingId: number, buildingUpdate:
|
||||
/**
|
||||
* 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'])) {
|
||||
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
||||
}
|
||||
if('demolished_buildings' in buildingUpdate) {
|
||||
buildingUpdate = await processDynamicsPastBuildings(buildingId, buildingUpdate);
|
||||
if(hasAnyOwnProperty(buildingUpdate, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
|
||||
buildingUpdate = await processDynamicsDemolishedBuildings(buildingId, buildingUpdate);
|
||||
}
|
||||
|
||||
return buildingUpdate;
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -6,7 +6,7 @@ import { apiPost } from '../apiHelpers';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { compareObjects } from '../helpers';
|
||||
import { Building, UserVerified } from '../models/building';
|
||||
import { Building, BuildingAttributes, UserVerified } from '../models/building';
|
||||
import { User } from '../models/user';
|
||||
|
||||
import ContainerHeader from './container-header';
|
||||
@ -72,6 +72,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleVerify = this.handleVerify.bind(this);
|
||||
this.handleSaveAdd = this.handleSaveAdd.bind(this);
|
||||
this.handleSaveChange = this.handleSaveChange.bind(this);
|
||||
|
||||
this.toggleCopying = this.toggleCopying.bind(this);
|
||||
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
|
||||
@ -190,14 +191,12 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
async doSubmit(edits: Partial<BuildingAttributes>) {
|
||||
this.setState({error: undefined});
|
||||
|
||||
try {
|
||||
const data = await apiPost(
|
||||
`/api/buildings/${this.props.building.building_id}.json`,
|
||||
this.state.buildingEdits
|
||||
edits
|
||||
);
|
||||
|
||||
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) {
|
||||
const verifyPatch = {};
|
||||
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() {
|
||||
const currentBuilding = this.getEditedBuilding();
|
||||
|
||||
@ -356,6 +359,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
||||
onLike={undefined}
|
||||
onVerify={undefined}
|
||||
onSaveAdd={undefined}
|
||||
onSaveChange={undefined}
|
||||
user_verified={[]}
|
||||
/>
|
||||
</Fragment> :
|
||||
@ -408,6 +412,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
||||
onLike={this.handleLike}
|
||||
onVerify={this.handleVerify}
|
||||
onSaveAdd={this.handleSaveAdd}
|
||||
onSaveChange={this.handleSaveChange}
|
||||
user_verified={this.props.user_verified}
|
||||
user={this.props.user}
|
||||
/>
|
||||
|
@ -21,6 +21,9 @@ interface CategoryViewProps {
|
||||
/* Special handler for adding and immediately saving a new item of an array-like attribute */
|
||||
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?: any;
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import { NumberRangeDataEntry } from './number-range-data-entry';
|
||||
|
||||
import './dynamics-data-entry.css';
|
||||
import { CloseIcon } from '../../../components/icons';
|
||||
import DataTitle, { DataTitleCopyable } from '../../data-components/data-title';
|
||||
|
||||
type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]);
|
||||
|
||||
@ -147,6 +148,7 @@ const DynamicsDataRow: React.FC<DynamicsDataRowProps> = ({
|
||||
};
|
||||
|
||||
interface DynamicsDataEntryProps extends BaseDataEntryProps {
|
||||
title: string;
|
||||
value: DemolishedBuilding[];
|
||||
editableEntries: boolean;
|
||||
maxYear: number;
|
||||
@ -206,11 +208,13 @@ export const DynamicsDataEntry: React.FC<DynamicsDataEntryProps> = (props) => {
|
||||
<>
|
||||
<div>
|
||||
{
|
||||
isEditing &&
|
||||
isEditing ?
|
||||
<>
|
||||
<h6 className="h6">Existing records for demolished buildings</h6>
|
||||
<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">
|
||||
{
|
||||
|
@ -13,6 +13,7 @@ import NumericDataEntry from '../../data-components/numeric-data-entry';
|
||||
import withCopyEdit from '../../data-container';
|
||||
|
||||
import { CategoryViewProps } from '../category-view-props';
|
||||
import { LogicalDataEntry } from '../../data-components/logical-data-entry/logical-data-entry';
|
||||
|
||||
/**
|
||||
* Dynamics view/edit section
|
||||
@ -60,7 +61,23 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
</FieldRow>
|
||||
</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
|
||||
|
||||
@ -73,7 +90,7 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
value={building.demolished_buildings}
|
||||
editableEntries={true}
|
||||
slug='demolished_buildings'
|
||||
title={undefined}
|
||||
title={dataFields.demolished_buildings.title}
|
||||
mode={props.mode}
|
||||
onChange={props.onChange}
|
||||
onSaveAdd={props.onSaveAdd}
|
||||
@ -85,10 +102,10 @@ const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
props.mode === 'view' &&
|
||||
<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 name="Future planned data collection" collapsed={false} showCount={false}>
|
||||
|
@ -188,7 +188,7 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
||||
mapStyle: 'dynamics_demolished_count',
|
||||
legend: {
|
||||
title: 'Dynamics',
|
||||
description: 'Past (demolished) buildings on site',
|
||||
description: 'Demolished buildings on the same site',
|
||||
elements: [
|
||||
{
|
||||
text: '7+',
|
||||
@ -211,6 +211,9 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
||||
}, {
|
||||
text: '1',
|
||||
color: '#ffe8a9',
|
||||
}, {
|
||||
text: 'None',
|
||||
color: '#0C7BDC'
|
||||
}
|
||||
],
|
||||
},
|
||||
|
@ -7,11 +7,44 @@ import { Category } from './categories-config';
|
||||
* e.g. dataFields.foo_bar would not be highlighted as an error.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* The human-readable title of the field to be displayed as label.
|
||||
*/
|
||||
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;
|
||||
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 */
|
||||
@ -412,6 +445,12 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
||||
example: 100,
|
||||
},
|
||||
|
||||
dynamics_has_demolished_buildings: {
|
||||
category: Category.Dynamics,
|
||||
title: 'Where any other buildings ever built on this site?',
|
||||
example: true,
|
||||
},
|
||||
|
||||
demolished_buildings: {
|
||||
category: Category.Dynamics,
|
||||
title: 'Past (demolished) buildings on this site',
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -136,10 +136,11 @@ const LAYER_QUERIES = {
|
||||
dynamics_demolished_count: `
|
||||
SELECT
|
||||
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
|
||||
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';
|
||||
|
1
migrations/unreleased/0xx.dynamics-bool.down.sql
Normal file
1
migrations/unreleased/0xx.dynamics-bool.down.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE buildings DROP COLUMN IF EXISTS dynamics_has_demolished_buildings;
|
7
migrations/unreleased/0xx.dynamics-bool.up.sql
Normal file
7
migrations/unreleased/0xx.dynamics-bool.up.sql
Normal 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;
|
Loading…
Reference in New Issue
Block a user