Add user opinion fields to community section
This commit is contained in:
parent
29ed25f36c
commit
8c8a6a8094
@ -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'
|
||||
}
|
||||
]
|
||||
};
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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}/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
5
app/src/frontend/building/data-containers/community.css
Normal file
5
app/src/frontend/building/data-containers/community.css
Normal file
@ -0,0 +1,5 @@
|
||||
.community-opinion-pane {
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 1px dashed gray;
|
||||
}
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
25
migrations/unreleased/0xx.community.down.sql
Normal file
25
migrations/unreleased/0xx.community.down.sql
Normal 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;
|
40
migrations/unreleased/0xx.community.up.sql
Normal file
40
migrations/unreleased/0xx.community.up.sql
Normal 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;
|
Loading…
Reference in New Issue
Block a user