Add user opinion fields to community section

This commit is contained in:
Maciej Ziarkowski 2021-09-24 20:31:03 +03:00
parent 29ed25f36c
commit 8c8a6a8094
11 changed files with 378 additions and 42 deletions

View File

@ -18,5 +18,11 @@ export const aggregationsConfig: { [key in keyof typeof buildingUserAttributesCo
aggregateFieldName: 'likes_total',
aggregationMethod: 'countTrue'
}
],
community_local_significance: [
{
aggregateFieldName: 'community_local_significance_total',
aggregationMethod: 'countTrue'
}
]
};

View File

@ -274,6 +274,11 @@ export const buildingAttributesConfig = valueType<DataFieldConfig>()({ /* eslint
edit: false,
derivedEdit: true,
verify: false
},
community_local_significance_total: {
edit: false,
derivedEdit: true,
verify: false
}
});
@ -284,6 +289,21 @@ export const buildingUserAttributesConfig = valueType<DataFieldConfig>()({
edit: true,
verify: false,
},
community_type_worth_keeping: {
perUser: true,
edit: true,
verify: false
},
community_type_worth_keeping_reasons: {
perUser: true,
edit: true,
verify: false
},
community_local_significance: {
perUser: true,
edit: true,
verify: false
}
});
export const allAttributesConfig = Object.assign({}, buildingAttributesConfig, buildingUserAttributesConfig);

View File

@ -63,4 +63,32 @@ export const fieldSchemaConfig: { [key in keyof typeof allAttributesConfig]?: So
links: string[];
}[]>,
community_type_worth_keeping_reasons: {
type: 'object',
properties: {
external_design: {
type: 'boolean',
nullable: true
},
internal_design: {
type: 'boolean',
nullable: true
},
adaptable: {
type: 'boolean',
nullable: true
},
other: {
type: 'boolean',
nullable: true
}
},
additionalProperties: false
} as JSONSchemaType<{
external_design: boolean,
internal_design: boolean,
adaptable: boolean,
other: boolean
}>
} as const;

View File

@ -44,6 +44,17 @@ const ToggleButton: React.FC<ToggleButtonProps> = ({
);
}
const ClearButton = ({
onClick,
disabled
}) => {
return <div className="btn-group btn-group-toggle">
<label>
<button type="button" className="btn btn-outline-warning" onClick={onClick} disabled={disabled}>Clear</button>
</label>
</div>
}
interface LogicalDataEntryProps extends BaseDataEntryProps {
value: boolean;
disallowTrue?: boolean;
@ -53,7 +64,11 @@ interface LogicalDataEntryProps extends BaseDataEntryProps {
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');
props.onChange?.(props.slug, e.target.value === 'true');
}
function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
props.onChange?.(props.slug, null);
}
const isDisabled = props.mode === 'view' || props.disabled;
@ -76,16 +91,6 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
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}
@ -95,6 +100,10 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
onChange={handleValueChange}
>No</ToggleButton>
</div>
{
!isDisabled && props.value != null &&
<ClearButton onClick={handleClear} disabled={props.disallowNull}/>
}
</>
);
};

View File

@ -0,0 +1,57 @@
import React, { ChangeEvent } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface MultiSelectOption {
key: string;
label: string;
}
interface MultiSelectDataEntryProps extends BaseDataEntryProps {
value: {[key: string]: boolean};
options: (MultiSelectOption)[];
showTitle?: boolean; // TODO make it an option for all input types
}
export const MultiSelectDataEntry: React.FunctionComponent<MultiSelectDataEntryProps> = (props) => {
const slugWithModifier = props.slug + (props.slugModifier ?? '');
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const changedKey = e.target.name;
const checked = e.target.checked;
const newVal = {...props.value, [changedKey]: checked || null};
props.onChange(slugWithModifier, newVal);
}
return (
<>
{props.showTitle !== false &&
<DataTitleCopyable
slug={props.slug}
slugModifier={props.slugModifier}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
}
{
props.options.map(o => (
<label>
<input
type="checkbox"
disabled={props.disabled}
name={o.key}
checked={props.value && props.value[o.key]}
onChange={handleChange}
/>
{o.label}
</label>
))
}
</>
);
};

View File

@ -1,48 +1,50 @@
import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import { buildingUserFields, dataFields } from '../../config/data-fields-config';
import { AggregationDescriptionConfig, buildingUserFields, dataFields } from '../../config/data-fields-config';
import { CopyProps } from '../data-containers/category-view-props';
import { DataTitleCopyable } from './data-title';
interface LikeDataEntryProps {
interface UserOpinionEntryProps {
slug: string;
title: string;
mode: 'view' | 'edit' | 'multi-edit';
userValue: boolean;
aggregateValue: number;
aggregationDescriptions: AggregationDescriptionConfig;
copy: CopyProps;
onChange: (key: string, value: boolean) => void;
}
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
const fieldName = 'community_like';
const UserOpinionEntry: React.FunctionComponent<UserOpinionEntryProps> = (props) => {
return (
<>
<DataTitleCopyable
slug={fieldName}
title={buildingUserFields.community_like.title}
slug={props.slug}
title={props.title}
copy={props.copy}
/>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name="like"
name={props.slug}
checked={!!props.userValue}
disabled={props.mode === 'view'}
onChange={e => props.onChange(fieldName, e.target.checked)}
onChange={e => props.onChange(props.slug, e.target.checked)}
/> Yes
</label>
<p>
{
(props.aggregateValue != null)?
(props.aggregateValue)?
(props.aggregateValue === 1)?
`${props.aggregateValue} person likes this building`
: `${props.aggregateValue} people like this building`
: "0 people like this building so far - you could be the first!"
`1 person ${props.aggregationDescriptions.one}`
: `${props.aggregateValue} people ${props.aggregationDescriptions.many}`
: `0 people ${props.aggregationDescriptions.zero}`
}
</p>
</>
);
};
export default LikeDataEntry;
export default UserOpinionEntry;

View File

@ -0,0 +1,5 @@
.community-opinion-pane {
padding-top: 0.5em;
padding-bottom: 0.5em;
border-bottom: 1px dashed gray;
}

View File

@ -1,31 +1,86 @@
import React from 'react';
import withCopyEdit from '../data-container';
import LikeDataEntry from '../data-components/like-data-entry';
import UserOpinionEntry from '../data-components/user-opinion-data-entry';
import { MultiSelectDataEntry } from '../data-components/multi-select-data-entry';
import { CategoryViewProps } from './category-view-props';
import InfoBox from '../../components/info-box';
import { LogicalDataEntry } from '../data-components/logical-data-entry/logical-data-entry';
import { dataFields } from '../../config/data-fields-config';
import { buildingUserFields, dataFields } from '../../config/data-fields-config';
import './community.css';
/**
* Community view/edit section
*/
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
<>
<InfoBox>
Can you help add information on how well you think the building works, and on if it is in public ownership?
</InfoBox>
<LikeDataEntry
userValue={props.building.community_like}
aggregateValue={props.building.likes_total}
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
const worthKeepingReasonsNonEmpty = Object.values(props.building.community_type_worth_keeping_reasons ?? {}).some(x => x);
return <>
<div className='community-opinion-pane'>
<InfoBox>
Can you share your opinion on how well the building works?
</InfoBox>
<UserOpinionEntry
slug='community_like'
title={buildingUserFields.community_like.title}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
{/*
<LogicalDataEntry
userValue={props.building.community_like}
aggregateValue={props.building.likes_total}
aggregationDescriptions={dataFields.likes_total.aggregationDescriptions}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
<LogicalDataEntry
slug='community_type_worth_keeping'
title={buildingUserFields.community_type_worth_keeping.title}
value={props.building.community_type_worth_keeping}
disallowFalse={worthKeepingReasonsNonEmpty}
disallowNull={worthKeepingReasonsNonEmpty}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
{
props.building.community_type_worth_keeping !== false &&
<MultiSelectDataEntry
slug='community_type_worth_keeping_reasons'
title={buildingUserFields.community_type_worth_keeping_reasons.title}
value={props.building.community_type_worth_keeping_reasons}
disabled={!props.building.community_type_worth_keeping}
onChange={props.onSaveChange}
options={
Object.entries(buildingUserFields.community_type_worth_keeping_reasons.fields)
.map(([key, definition]) => ({
key,
label: definition.title
}))
}
mode={props.mode}
/>
}
<UserOpinionEntry
slug='community_local_significance'
title={buildingUserFields.community_local_significance.title}
userValue={props.building.community_local_significance}
aggregateValue={props.building.community_local_significance_total}
aggregationDescriptions={dataFields.community_local_significance_total.aggregationDescriptions}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
</div>
<InfoBox>Can you help add information about public ownership of the building?</InfoBox>
{/* <LogicalDataEntry
slug='community_publicly_owned'
title={dataFields.community_publicly_owned.title}
value={props.building.community_publicly_owned}
@ -55,7 +110,7 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
}
</ul> */}
</>
);
};
const CommunityContainer = withCopyEdit(CommunityView);
export default CommunityContainer;

View File

@ -1,5 +1,12 @@
import { Category } from './categories-config';
export interface AggregationDescriptionConfig {
zero: string;
one: string;
many: string;
}
/**
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
@ -35,6 +42,12 @@ export interface DataFieldDefinition {
*/
items?: { [key: string]: Omit<DataFieldDefinition, 'category'> };
/**
* If the defined type is a dictionary, this describes the types of the dictionary's fields
*/
fields?: { [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.
@ -52,6 +65,14 @@ export interface DataFieldDefinition {
* By default this is false - fields are treated as not user-specific.
*/
perUser?: boolean;
/**
* Only for fields that are aggregations of a building-user field.
* specify what text should be added to the number of users calculated by the aggregation.
* E.g. for user likes, if zero="like this building" then for a building with 0 likes,
* the result will be "0 people like this building"
*/
aggregationDescriptions?: AggregationDescriptionConfig;
}
export const buildingUserFields = {
@ -61,6 +82,44 @@ export const buildingUserFields = {
title: "Do you like this building and think it contributes to the city?",
example: true,
},
community_type_worth_keeping: {
perUser: true,
category: Category.Community,
title: "Do you think this type of building is generally worth keeping?",
example: true,
},
community_type_worth_keeping_reasons: {
perUser: true,
category: Category.Community,
title: 'Why is this type of building worth keeping?',
fields: {
external_design: {
title: "because the external design contributes to the streetscape"
},
internal_design: {
title: 'because the internal design works well'
},
adaptable: {
title: 'because the building is adaptable / can be reused to make the city more sustainable'
},
other: {
title: 'other'
}
},
example: {
external_design: true,
internal_design: true,
adaptable: false,
other: false
}
},
community_local_significance: {
perUser: true,
category: Category.Community,
title: "Do you think this building should be recorded as one of special local significance?",
example: true
}
};
@ -461,9 +520,39 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
category: Category.Community,
title: "Total number of likes",
example: 100,
tooltip: "People who like the building and think it contributes to the city."
tooltip: "People who like the building and think it contributes to the city.",
aggregationDescriptions: {
zero: "like this building so far",
one: "likes this building",
many: "like this building"
}
},
community_local_significance_total: {
category: Category.Community,
title: "People who think the building should be recorded as one of local significance",
example: 100,
aggregationDescriptions: {
zero: "think this building is of local significance",
one: "thinks this building is of local significance",
many: "think this building is of local significance"
}
},
community_publicly_owned: {
category: Category.Community,
title: "Is the building in some form of community ownership?",
example: false
},
community_public_ownership_form: {
category: Category.Community,
title: "What is the form of community ownership of this building?",
example: "State-owned"
},
community_public_ownership_source: {
category: Category.Community,
title: "Community ownership source links",
example: "https://example.com"
},
dynamics_has_demolished_buildings: {

View File

@ -0,0 +1,25 @@
-- -- Remove community fields
-- -- Ownership type, enumerate type from:
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type;
-- -- Ownerhsip perception, would you describe this as a community asset?
-- -- Boolean yes / no
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception;
-- -- Historic ownership type / perception
-- -- Has this building ever been used for community or public services activities?
-- -- Boolean yes / no
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_type_worth_keeping;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_type_worth_keeping_reasons;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_local_significance;
ALTER TABLE buildings
DROP COLUMN IF EXISTS community_local_significance_total;

View File

@ -0,0 +1,40 @@
-- Remove community fields
-- Ownership type, enumerate type from:
--
-- CREATE TYPE ownership_type
-- AS ENUM ('Private individual',
-- 'Private company',
-- 'Private offshore ownership',
-- 'Publicly owned',
-- 'Institutionally owned');
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual';
-- Ownerhsip perception, would you describe this as a community asset?
-- Boolean yes / no
-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null;
-- Historic ownership type / perception
-- Has this building ever been used for community or public services activities?
-- Boolean yes / no
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null;
ALTER TABLE building_user_attributes
ADD COLUMN community_type_worth_keeping BOOLEAN NULL;
ALTER TABLE building_user_attributes
ADD COLUMN community_type_worth_keeping_reasons JSONB DEFAULT '{}'::JSONB;
ALTER TABLE building_user_attributes
ADD COLUMN community_local_significance BOOLEAN DEFAULT false;
ALTER TABLE buildings
ADD COLUMN community_local_significance_total INT DEFAULT 0;