diff --git a/webapp/src/blocks/board.ts b/webapp/src/blocks/board.ts
index 5038c6af4..afceb844e 100644
--- a/webapp/src/blocks/board.ts
+++ b/webapp/src/blocks/board.ts
@@ -74,7 +74,7 @@ type BoardsAndBlocksPatch = {
     blockPatches: BlockPatch[],
 }
 
-type PropertyType = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy'
+type PropertyTypeEnum = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy' | 'unknown'
 
 interface IPropertyOption {
     id: string
@@ -86,7 +86,7 @@ interface IPropertyOption {
 interface IPropertyTemplate {
     id: string
     name: string
-    type: PropertyType
+    type: PropertyTypeEnum
     options: IPropertyOption[]
 }
 
@@ -310,7 +310,7 @@ export {
     BoardMember,
     BoardsAndBlocks,
     BoardsAndBlocksPatch,
-    PropertyType,
+    PropertyTypeEnum,
     IPropertyOption,
     IPropertyTemplate,
     BoardGroup,
diff --git a/webapp/src/blocks/filterClause.ts b/webapp/src/blocks/filterClause.ts
index 1cd6bd349..166b05c24 100644
--- a/webapp/src/blocks/filterClause.ts
+++ b/webapp/src/blocks/filterClause.ts
@@ -2,7 +2,7 @@
 // See LICENSE.txt for license information.
 import {Utils} from '../utils'
 
-type FilterCondition = 'includes' | 'notIncludes' | 'isEmpty' | 'isNotEmpty'
+type FilterCondition = 'includes' | 'notIncludes' | 'isEmpty' | 'isNotEmpty' | 'isSet' | 'isNotSet' | 'is' | 'contains' | 'notContains' | 'startsWith' | 'notStartsWith' | 'endsWith' | 'notEndsWith'
 
 type FilterClause = {
     propertyId: string
diff --git a/webapp/src/cardFilter.ts b/webapp/src/cardFilter.ts
index 49dab2c47..2a229260b 100644
--- a/webapp/src/cardFilter.ts
+++ b/webapp/src/cardFilter.ts
@@ -44,7 +44,10 @@ class CardFilter {
     }
 
     static isClauseMet(filter: FilterClause, templates: readonly IPropertyTemplate[], card: Card): boolean {
-        const value = card.fields.properties[filter.propertyId]
+        let value = card.fields.properties[filter.propertyId]
+        if (filter.propertyId === 'title') {
+            value = card.title
+        }
         switch (filter.condition) {
         case 'includes': {
             if (filter.values?.length < 1) {
@@ -64,6 +67,54 @@ class CardFilter {
         case 'isNotEmpty': {
             return (value || '').length > 0
         }
+        case 'isSet': {
+            return Boolean(value)
+        }
+        case 'isNotSet': {
+            return !value
+        }
+        case 'is': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return filter.values[0] === value
+        }
+        case 'contains': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return (value as string || '').includes(filter.values[0])
+        }
+        case 'notContains': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return (value as string || '').includes(filter.values[0])
+        }
+        case 'startsWith': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return (value as string || '').startsWith(filter.values[0])
+        }
+        case 'notStartsWith': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return !(value as string || '').startsWith(filter.values[0])
+        }
+        case 'endsWith': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return (value as string || '').endsWith(filter.values[0])
+        }
+        case 'notEndsWith': {
+            if (filter.values.length === 0) {
+                return true
+            }
+            return !(value as string || '').endsWith(filter.values[0])
+        }
         default: {
             Utils.assertFailure(`Invalid filter condition ${filter.condition}`)
         }
diff --git a/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap b/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap
index b058df775..21f9a79c9 100644
--- a/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap
+++ b/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap
@@ -177,40 +177,3 @@ exports[`components/propertyValueElement should match snapshot, url, array value
   </div>
 </div>
 `;
-
-exports[`components/propertyValueElement should match snapshot, url, array value 2`] = `
-<div>
-  <div
-    class="URLProperty octo-propertyvalue"
-  >
-    <a
-      class="link"
-      href="http://localhost"
-      rel="noreferrer"
-      target="_blank"
-    >
-      http://localhost
-    </a>
-    <button
-      aria-label="Edit"
-      class="IconButton Button_Edit"
-      title="Edit"
-      type="button"
-    >
-      <i
-        class="CompassIcon icon-pencil-outline EditIcon"
-      />
-    </button>
-    <button
-      aria-label="Copy"
-      class="IconButton Button_Copy"
-      title="Copy"
-      type="button"
-    >
-      <i
-        class="CompassIcon icon-content-copy content-copy"
-      />
-    </button>
-  </div>
-</div>
-`;
diff --git a/webapp/src/components/calculations/calculations.ts b/webapp/src/components/calculations/calculations.ts
index fadfe8aab..47d4a2c63 100644
--- a/webapp/src/components/calculations/calculations.ts
+++ b/webapp/src/components/calculations/calculations.ts
@@ -9,7 +9,7 @@ import {Card} from '../../blocks/card'
 import {IPropertyTemplate} from '../../blocks/board'
 import {Utils} from '../../utils'
 import {Constants} from '../../constants'
-import {DateProperty} from '../properties/dateRange/dateRange'
+import {DateProperty} from '../../properties/date/date'
 
 const ROUNDED_DECIMAL_PLACES = 2
 
diff --git a/webapp/src/components/calculations/options.tsx b/webapp/src/components/calculations/options.tsx
index 063d0b1f2..9972468e4 100644
--- a/webapp/src/components/calculations/options.tsx
+++ b/webapp/src/components/calculations/options.tsx
@@ -12,7 +12,7 @@ import {getSelectBaseStyle} from '../../theme'
 import ChevronUp from '../../widgets/icons/chevronUp'
 import {IPropertyTemplate} from '../../blocks/board'
 
-type Option = {
+export type Option = {
     label: string
     value: string
     displayName: string
@@ -160,7 +160,7 @@ const DropdownIndicator = (props: DropdownIndicatorProps<Option, false>) => {
 }
 
 // Calculation option props shared by all implementations of calculation options
-type CommonCalculationOptionProps = {
+export type CommonCalculationOptionProps = {
     value: string,
     menuOpen: boolean
     onClose?: () => void
@@ -174,7 +174,7 @@ type BaseCalculationOptionProps = CommonCalculationOptionProps & {
     options: Option[]
 }
 
-const CalculationOptions = (props: BaseCalculationOptionProps): JSX.Element => {
+export const CalculationOptions = (props: BaseCalculationOptionProps): JSX.Element => {
     const intl = useIntl()
 
     return (
@@ -208,9 +208,3 @@ const CalculationOptions = (props: BaseCalculationOptionProps): JSX.Element => {
         />
     )
 }
-
-export {
-    CalculationOptions,
-    Option,
-    CommonCalculationOptionProps,
-}
diff --git a/webapp/src/components/calendar/fullCalendar.tsx b/webapp/src/components/calendar/fullCalendar.tsx
index 2a2a3584e..5a9b56557 100644
--- a/webapp/src/components/calendar/fullCalendar.tsx
+++ b/webapp/src/components/calendar/fullCalendar.tsx
@@ -14,7 +14,8 @@ import mutator from '../../mutator'
 import {Board, IPropertyTemplate} from '../../blocks/board'
 import {BoardView} from '../../blocks/boardView'
 import {Card} from '../../blocks/card'
-import {DateProperty, createDatePropertyFromString} from '../properties/dateRange/dateRange'
+import {DateProperty} from '../../properties/date/date'
+import propsRegistry from '../../properties'
 import Tooltip from '../../widgets/tooltip'
 import PropertyValueElement from '../propertyValueElement'
 import {Constants, Permission} from '../../constants'
@@ -84,7 +85,7 @@ const CalendarFullView = (props: Props): JSX.Element|null => {
     }
 
     const isEditable = useCallback(() : boolean => {
-        if (readonly || !dateDisplayProperty || (dateDisplayProperty.type === 'createdTime' || dateDisplayProperty.type === 'updatedTime')) {
+        if (readonly || !dateDisplayProperty || propsRegistry.get(dateDisplayProperty.type).isReadOnly) {
             return false
         }
         return true
@@ -92,23 +93,12 @@ const CalendarFullView = (props: Props): JSX.Element|null => {
 
     const myEventsList = useMemo(() => (
         cards.flatMap((card): EventInput[] => {
+            const property = propsRegistry.get(dateDisplayProperty?.type || 'unknown')
             let dateFrom = new Date(card.createAt || 0)
             let dateTo = new Date(card.createAt || 0)
-            if (dateDisplayProperty && dateDisplayProperty?.type === 'updatedTime') {
-                dateFrom = new Date(card.updateAt || 0)
-                dateTo = new Date(card.updateAt || 0)
-            } else if (dateDisplayProperty && dateDisplayProperty?.type !== 'createdTime') {
-                const dateProperty = createDatePropertyFromString(card.fields.properties[dateDisplayProperty.id || ''] as string)
-                if (!dateProperty.from) {
-                    return []
-                }
-
-                // date properties are stored as 12 pm UTC, convert to 12 am (00) UTC for calendar
-                dateFrom = dateProperty.from ? new Date(dateProperty.from + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.from))) : new Date()
-                dateFrom.setHours(0, 0, 0, 0)
-                const dateToNumber = dateProperty.to ? dateProperty.to + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.to)) : dateFrom.getTime()
-                dateTo = new Date(dateToNumber + oneDay) // Add one day.
-                dateTo.setHours(0, 0, 0, 0)
+            if (property.isDate && property.getDateFrom && property.getDateTo) {
+                dateFrom = property.getDateFrom(card.fields.properties[dateDisplayProperty?.id || ''], card)
+                dateTo = property.getDateTo(card.fields.properties[dateDisplayProperty?.id || ''], card)
             }
             return [{
                 id: card.id,
diff --git a/webapp/src/components/cardDetail/__snapshots__/cardDetailProperties.test.tsx.snap b/webapp/src/components/cardDetail/__snapshots__/cardDetailProperties.test.tsx.snap
index eccba6d80..84ce157fc 100644
--- a/webapp/src/components/cardDetail/__snapshots__/cardDetailProperties.test.tsx.snap
+++ b/webapp/src/components/cardDetail/__snapshots__/cardDetailProperties.test.tsx.snap
@@ -66,7 +66,6 @@ exports[`components/cardDetail/CardDetailProperties cancel on delete dialog shou
       <input
         class="Editable octo-propertyvalue"
         placeholder="Empty"
-        spellcheck="false"
         style="width: 5px;"
         title="1234"
         value="1234"
@@ -160,7 +159,6 @@ exports[`components/cardDetail/CardDetailProperties should match snapshot 1`] =
       <input
         class="Editable octo-propertyvalue"
         placeholder="Empty"
-        spellcheck="false"
         style="width: 5px;"
         title="1234"
         value="1234"
@@ -254,7 +252,6 @@ exports[`components/cardDetail/CardDetailProperties should show property types m
       <input
         class="Editable octo-propertyvalue"
         placeholder="Empty"
-        spellcheck="false"
         style="width: 5px;"
         title="1234"
         value="1234"
diff --git a/webapp/src/components/cardDetail/cardDetail.scss b/webapp/src/components/cardDetail/cardDetail.scss
index bd7a80a3e..76c76101e 100644
--- a/webapp/src/components/cardDetail/cardDetail.scss
+++ b/webapp/src/components/cardDetail/cardDetail.scss
@@ -117,7 +117,7 @@
         }
     }
 
-    .UserProperty {
+    .Person {
         .react-select__value-container {
             padding: 0;
         }
diff --git a/webapp/src/components/cardDetail/cardDetailProperties.test.tsx b/webapp/src/components/cardDetail/cardDetailProperties.test.tsx
index 97c1d50f2..6ddcb691f 100644
--- a/webapp/src/components/cardDetail/cardDetailProperties.test.tsx
+++ b/webapp/src/components/cardDetail/cardDetailProperties.test.tsx
@@ -11,11 +11,11 @@ import {createIntl} from 'react-intl'
 import configureStore from 'redux-mock-store'
 import {Provider as ReduxProvider} from 'react-redux'
 
-import {PropertyType} from '../../blocks/board'
 import {wrapIntl} from '../../testUtils'
 import {TestBlockFactory} from '../../test/testBlockFactory'
 import mutator from '../../mutator'
-import {propertyTypesList, typeDisplayName} from '../../widgets/propertyMenu'
+import propsRegistry from '../../properties'
+import {PropertyType} from '../../properties/types'
 
 import CardDetailProperties from './cardDetailProperties'
 
@@ -159,8 +159,8 @@ describe('components/cardDetail/CardDetailProperties', () => {
         const selectProperty = screen.getByText(/select property type/i)
         expect(selectProperty).toBeInTheDocument()
 
-        propertyTypesList.forEach((type: PropertyType) => {
-            const typeButton = screen.getByRole('button', {name: typeDisplayName(intl, type)})
+        propsRegistry.list().forEach((type: PropertyType) => {
+            const typeButton = screen.getByRole('button', {name: type.displayName(intl)})
             expect(typeButton).toBeInTheDocument()
         })
     })
diff --git a/webapp/src/components/cardDetail/cardDetailProperties.tsx b/webapp/src/components/cardDetail/cardDetailProperties.tsx
index 88e491d63..09969e84e 100644
--- a/webapp/src/components/cardDetail/cardDetailProperties.tsx
+++ b/webapp/src/components/cardDetail/cardDetailProperties.tsx
@@ -3,14 +3,14 @@
 import React, {useEffect, useState} from 'react'
 import {FormattedMessage, useIntl} from 'react-intl'
 
-import {Board, IPropertyTemplate, PropertyType} from '../../blocks/board'
+import {Board, IPropertyTemplate} from '../../blocks/board'
 import {Card} from '../../blocks/card'
 import {BoardView} from '../../blocks/boardView'
 
 import mutator from '../../mutator'
 import Button from '../../widgets/buttons/button'
 import MenuWrapper from '../../widgets/menuWrapper'
-import PropertyMenu, {PropertyTypes, typeDisplayName} from '../../widgets/propertyMenu'
+import PropertyMenu, {PropertyTypes} from '../../widgets/propertyMenu'
 
 import Calculations from '../calculations/calculations'
 import PropertyValueElement from '../propertyValueElement'
@@ -21,6 +21,8 @@ import {IDType, Utils} from '../../utils'
 import AddPropertiesTourStep from '../onboardingTour/addProperties/add_properties'
 import {Permission} from '../../constants'
 import {useHasCurrentBoardPermissions} from '../../hooks/permissions'
+import propRegistry from '../../properties'
+import {PropertyType} from '../../properties/types'
 
 type Props = {
     board: Board
@@ -49,7 +51,7 @@ const CardDetailProperties = (props: Props) => {
     const [showConfirmationDialog, setShowConfirmationDialog] = useState<boolean>(false)
 
     function onPropertyChangeSetAndOpenConfirmationDialog(newType: PropertyType, newName: string, propertyTemplate:IPropertyTemplate) {
-        const oldType = propertyTemplate.type
+        const oldType = propRegistry.get(propertyTemplate.type)
 
         // do nothing if no change
         if (oldType === newType && propertyTemplate.name === newName) {
@@ -60,7 +62,7 @@ const CardDetailProperties = (props: Props) => {
 
         // if only the name has changed, set the property without warning
         if (affectsNumOfCards === '0' || oldType === newType) {
-            mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType, newName)
+            mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType.type, newName)
             return
         }
 
@@ -85,7 +87,7 @@ const CardDetailProperties = (props: Props) => {
             onConfirm: async () => {
                 setShowConfirmationDialog(false)
                 try {
-                    await mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType, newName)
+                    await mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType.type, newName)
                 } catch (err:any) {
                     Utils.logError(`Error Changing Property And Name:${propertyTemplate.name}: ${err?.toString()}`)
                 }
@@ -141,7 +143,7 @@ const CardDetailProperties = (props: Props) => {
                                 <PropertyMenu
                                     propertyId={propertyTemplate.id}
                                     propertyName={propertyTemplate.name}
-                                    propertyType={propertyTemplate.type}
+                                    propertyType={propRegistry.get(propertyTemplate.type)}
                                     onTypeAndNameChanged={(newType: PropertyType, newName: string) => onPropertyChangeSetAndOpenConfirmationDialog(newType, newName, propertyTemplate)}
                                     onDelete={() => onPropertyDeleteSetAndOpenConfirmationDialog(propertyTemplate)}
                                 />
@@ -179,8 +181,8 @@ const CardDetailProperties = (props: Props) => {
                                 onTypeSelected={async (type) => {
                                     const template: IPropertyTemplate = {
                                         id: Utils.createGuid(IDType.BlockID),
-                                        name: typeDisplayName(intl, type),
-                                        type,
+                                        name: type.displayName(intl),
+                                        type: type.type,
                                         options: [],
                                     }
                                     const templateId = await mutator.insertPropertyTemplate(board, activeView, -1, template)
diff --git a/webapp/src/components/kanban/calculation/calculationOptions.tsx b/webapp/src/components/kanban/calculation/calculationOptions.tsx
index 27a695613..c0e0a2d6a 100644
--- a/webapp/src/components/kanban/calculation/calculationOptions.tsx
+++ b/webapp/src/components/kanban/calculation/calculationOptions.tsx
@@ -7,7 +7,7 @@ import {
     CommonCalculationOptionProps,
     optionsByType,
 } from '../../calculations/options'
-import {IPropertyTemplate, PropertyType} from '../../../blocks/board'
+import {IPropertyTemplate, PropertyTypeEnum} from '../../../blocks/board'
 
 import './calculationOption.scss'
 import {Option, OptionProps} from './kanbanOption'
@@ -18,12 +18,12 @@ type Props = CommonCalculationOptionProps & {
 }
 
 // contains mapping of property types which are effectly the same as other property type.
-const equivalentPropertyType = new Map<PropertyType, PropertyType>([
+const equivalentPropertyType = new Map<PropertyTypeEnum, PropertyTypeEnum>([
     ['createdTime', 'date'],
     ['updatedTime', 'date'],
 ])
 
-export function getEquivalentPropertyType(propertyType: PropertyType): PropertyType {
+export function getEquivalentPropertyType(propertyType: PropertyTypeEnum): PropertyTypeEnum {
     return equivalentPropertyType.get(propertyType) || propertyType
 }
 
diff --git a/webapp/src/components/properties/createdAt/createdAt.tsx b/webapp/src/components/properties/createdAt/createdAt.tsx
deleted file mode 100644
index 4bee7e444..000000000
--- a/webapp/src/components/properties/createdAt/createdAt.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React from 'react'
-
-import {useIntl} from 'react-intl'
-
-import {Utils} from '../../../utils'
-import {propertyValueClassName} from '../../propertyValueUtils'
-import './createdAt.scss'
-
-type Props = {
-    createAt: number
-}
-
-const CreatedAt = (props: Props): JSX.Element => {
-    const intl = useIntl()
-    return (
-        <div className={`CreatedAt ${propertyValueClassName({readonly: true})}`}>
-            {Utils.displayDateTime(new Date(props.createAt), intl)}
-        </div>
-    )
-}
-
-export default CreatedAt
diff --git a/webapp/src/components/properties/createdBy/__snapshots__/createdBy.test.tsx.snap b/webapp/src/components/properties/createdBy/__snapshots__/createdBy.test.tsx.snap
deleted file mode 100644
index e7613c520..000000000
--- a/webapp/src/components/properties/createdBy/__snapshots__/createdBy.test.tsx.snap
+++ /dev/null
@@ -1,15 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`components/properties/createdBy should match snapshot 1`] = `
-<div>
-  <div
-    class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
-  >
-    <div
-      class="UserProperty-item"
-    >
-      username_1
-    </div>
-  </div>
-</div>
-`;
diff --git a/webapp/src/components/properties/createdBy/createdBy.tsx b/webapp/src/components/properties/createdBy/createdBy.tsx
deleted file mode 100644
index c100366c5..000000000
--- a/webapp/src/components/properties/createdBy/createdBy.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React from 'react'
-
-import UserProperty from '../user/user'
-
-type Props = {
-    userID: string
-}
-
-const CreatedBy = (props: Props): JSX.Element => {
-    return (
-        <UserProperty
-            value={props.userID}
-            readonly={true} // created by is an immutable property, so will always be readonly
-            onChange={() => {}} // since created by is immutable, we don't need to handle onChange
-        />
-    )
-}
-
-export default CreatedBy
diff --git a/webapp/src/components/properties/lastModifiedAt/__snapshots__/lastModifiedAt.test.tsx.snap b/webapp/src/components/properties/lastModifiedAt/__snapshots__/lastModifiedAt.test.tsx.snap
deleted file mode 100644
index 9bf909287..000000000
--- a/webapp/src/components/properties/lastModifiedAt/__snapshots__/lastModifiedAt.test.tsx.snap
+++ /dev/null
@@ -1,11 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`components/properties/lastModifiedAt should match snapshot 1`] = `
-<div>
-  <div
-    class="LastModifiedAt octo-propertyvalue octo-propertyvalue--readonly"
-  >
-    June 15, 2021, 4:22 PM
-  </div>
-</div>
-`;
diff --git a/webapp/src/components/properties/lastModifiedBy/__snapshots__/lastModifiedBy.test.tsx.snap b/webapp/src/components/properties/lastModifiedBy/__snapshots__/lastModifiedBy.test.tsx.snap
deleted file mode 100644
index b87f1be99..000000000
--- a/webapp/src/components/properties/lastModifiedBy/__snapshots__/lastModifiedBy.test.tsx.snap
+++ /dev/null
@@ -1,15 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`components/properties/lastModifiedBy should match snapshot 1`] = `
-<div>
-  <div
-    class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
-  >
-    <div
-      class="UserProperty-item"
-    >
-      username_1
-    </div>
-  </div>
-</div>
-`;
diff --git a/webapp/src/components/properties/link/link.tsx b/webapp/src/components/properties/link/link.tsx
deleted file mode 100644
index 46ff5da07..000000000
--- a/webapp/src/components/properties/link/link.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React, {useEffect, useRef, useState} from 'react'
-import {useIntl} from 'react-intl'
-
-import Editable, {Focusable} from '../../../widgets/editable'
-
-import './link.scss'
-import {Utils} from '../../../utils'
-import EditIcon from '../../../widgets/icons/edit'
-import IconButton from '../../../widgets/buttons/iconButton'
-import DuplicateIcon from '../../../widgets/icons/duplicate'
-import {sendFlashMessage} from '../../flashMessages'
-import {propertyValueClassName} from '../../propertyValueUtils'
-
-type Props = {
-    value: string
-    readonly?: boolean
-    placeholder?: string
-    onChange: (value: string) => void
-    onSave: () => void
-    onCancel: () => void
-    validator: (newValue: string) => boolean
-}
-
-const URLProperty = (props: Props): JSX.Element => {
-    const [isEditing, setIsEditing] = useState(false)
-    const isEmpty = !props.value?.trim()
-    const showEditable = !props.readonly && (isEditing || isEmpty)
-    const editableRef = useRef<Focusable>(null)
-    const intl = useIntl()
-
-    useEffect(() => {
-        if (isEditing) {
-            editableRef.current?.focus()
-        }
-    }, [isEditing])
-
-    if (showEditable) {
-        return (
-            <div className='URLProperty'>
-                <Editable
-                    className={propertyValueClassName()}
-                    ref={editableRef}
-                    placeholderText={props.placeholder}
-                    value={props.value}
-                    autoExpand={true}
-                    readonly={props.readonly}
-                    onChange={props.onChange}
-                    onSave={() => {
-                        setIsEditing(false)
-                        props.onSave()
-                    }}
-                    onCancel={() => {
-                        setIsEditing(false)
-                        props.onCancel()
-                    }}
-                    onFocus={() => {
-                        setIsEditing(true)
-                    }}
-                    validator={props.validator}
-                />
-            </div>
-        )
-    }
-
-    return (
-        <div className={`URLProperty ${propertyValueClassName({readonly: props.readonly})}`}>
-            <a
-                className='link'
-                href={Utils.ensureProtocol(props.value.trim())}
-                target='_blank'
-                rel='noreferrer'
-                onClick={(event) => event.stopPropagation()}
-            >
-                {props.value}
-            </a>
-            {!props.readonly &&
-            <IconButton
-                className='Button_Edit'
-                title={intl.formatMessage({id: 'URLProperty.edit', defaultMessage: 'Edit'})}
-                icon={<EditIcon/>}
-                onClick={() => setIsEditing(true)}
-            />}
-            <IconButton
-                className='Button_Copy'
-                title={intl.formatMessage({id: 'URLProperty.copy', defaultMessage: 'Copy'})}
-                icon={<DuplicateIcon/>}
-                onClick={(e) => {
-                    e.stopPropagation()
-                    Utils.copyTextToClipboard(props.value)
-                    sendFlashMessage({content: intl.formatMessage({id: 'URLProperty.copiedLink', defaultMessage: 'Copied!'}), severity: 'high'})
-                }}
-            />
-        </div>
-    )
-}
-
-export default URLProperty
diff --git a/webapp/src/components/properties/multiSelect/multiSelect.tsx b/webapp/src/components/properties/multiSelect/multiSelect.tsx
deleted file mode 100644
index 6fd34acc0..000000000
--- a/webapp/src/components/properties/multiSelect/multiSelect.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React, {useState} from 'react'
-
-import {IPropertyOption, IPropertyTemplate} from '../../../blocks/board'
-
-import Label from '../../../widgets/label'
-
-import ValueSelector from '../../../widgets/valueSelector'
-import {propertyValueClassName} from '../../propertyValueUtils'
-
-type Props = {
-    emptyValue: string;
-    propertyTemplate: IPropertyTemplate;
-    propertyValue: string | string[];
-    onChange: (value: string | string[]) => void;
-    onChangeColor: (option: IPropertyOption, color: string) => void;
-    onDeleteOption: (option: IPropertyOption) => void;
-    onCreate: (newValue: string, currentValues: IPropertyOption[]) => void;
-    onDeleteValue: (valueToDelete: IPropertyOption, currentValues: IPropertyOption[]) => void;
-    isEditable: boolean;
-}
-
-const MultiSelectProperty = (props: Props): JSX.Element => {
-    const {propertyTemplate, emptyValue, propertyValue, isEditable, onChange, onChangeColor, onDeleteOption, onCreate, onDeleteValue} = props
-    const [open, setOpen] = useState(false)
-
-    const values = Array.isArray(propertyValue) && propertyValue.length > 0 ? propertyValue.map((v) => propertyTemplate.options.find((o) => o!.id === v)).filter((v): v is IPropertyOption => Boolean(v)) : []
-
-    if (!isEditable || !open) {
-        return (
-            <div
-                className={propertyValueClassName({readonly: !isEditable})}
-                tabIndex={0}
-                data-testid='multiselect-non-editable'
-                onClick={() => setOpen(true)}
-            >
-                {values.map((v) => (
-                    <Label
-                        key={v.id}
-                        color={v.color}
-                    >
-                        {v.value}
-                    </Label>
-                ))}
-                {values.length === 0 && (
-                    <Label
-                        color='empty'
-                    >{emptyValue}</Label>
-                )}
-            </div>
-        )
-    }
-
-    return (
-        <ValueSelector
-            isMulti={true}
-            emptyValue={emptyValue}
-            options={propertyTemplate.options}
-            value={values}
-            onChange={onChange}
-            onChangeColor={onChangeColor}
-            onDeleteOption={onDeleteOption}
-            onDeleteValue={(valueToRemove) => onDeleteValue(valueToRemove, values)}
-            onCreate={(newValue) => onCreate(newValue, values)}
-            onBlur={() => setOpen(false)}
-        />
-    )
-}
-
-export default MultiSelectProperty
diff --git a/webapp/src/components/properties/select/select.tsx b/webapp/src/components/properties/select/select.tsx
deleted file mode 100644
index 805fdf83f..000000000
--- a/webapp/src/components/properties/select/select.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React, {useState} from 'react'
-
-import {IPropertyOption, IPropertyTemplate} from '../../../blocks/board'
-
-import Label from '../../../widgets/label'
-import ValueSelector from '../../../widgets/valueSelector'
-import {propertyValueClassName} from '../../propertyValueUtils'
-
-type Props = {
-    emptyValue: string
-    propertyValue: string
-    propertyTemplate: IPropertyTemplate
-    onCreate: (value: string) => void
-    onChange: (value: string) => void
-    onChangeColor: (option: IPropertyOption, color: string) => void
-    onDeleteOption: (option: IPropertyOption) => void
-    onDeleteValue: () => void;
-    isEditable: boolean
-}
-
-const SelectProperty = (props: Props) => {
-    const {emptyValue, propertyValue, propertyTemplate, isEditable} = props
-    const [open, setOpen] = useState(false)
-
-    const option = propertyTemplate.options.find((o) => o.id === propertyValue)
-    const propertyColorCssClassName = option?.color || ''
-    const displayValue = option?.value
-    const finalDisplayValue = displayValue || emptyValue
-
-    if (!isEditable || !open) {
-        return (
-            <div
-                className={propertyValueClassName({readonly: !isEditable})}
-                data-testid='select-non-editable'
-                tabIndex={0}
-                onClick={() => setOpen(true)}
-            >
-                <Label color={displayValue ? propertyColorCssClassName : 'empty'}>
-                    <span className='Label-text'>{finalDisplayValue}</span>
-                </Label>
-            </div>
-        )
-    }
-    return (
-        <ValueSelector
-            emptyValue={emptyValue}
-            options={propertyTemplate.options}
-            value={propertyTemplate.options.find((p) => p.id === propertyValue)}
-            onCreate={props.onCreate}
-            onChange={(value) => props.onChange(value as string)}
-            onChangeColor={props.onChangeColor}
-            onDeleteOption={props.onDeleteOption}
-            onDeleteValue={props.onDeleteValue}
-            onBlur={() => setOpen(false)}
-        />
-    )
-}
-
-export default React.memo(SelectProperty)
diff --git a/webapp/src/components/propertyValueElement.test.tsx b/webapp/src/components/propertyValueElement.test.tsx
index 898d2880f..16f3287c9 100644
--- a/webapp/src/components/propertyValueElement.test.tsx
+++ b/webapp/src/components/propertyValueElement.test.tsx
@@ -88,30 +88,7 @@ describe('components/propertyValueElement', () => {
             type: 'url',
             options: [],
         }
-        card.fields.properties.property_url = ['http://localhost']
-
-        const component = wrapDNDIntl(
-            <PropertyValueElement
-                board={board}
-                readOnly={false}
-                card={card}
-                propertyTemplate={propertyTemplate}
-                showEmptyPlaceholder={true}
-            />,
-        )
-
-        const {container} = render(component)
-        expect(container).toMatchSnapshot()
-    })
-
-    test('should match snapshot, url, array value', () => {
-        const propertyTemplate: IPropertyTemplate = {
-            id: 'property_url',
-            name: 'Property URL',
-            type: 'url',
-            options: [],
-        }
-        card.fields.properties.property_url = ['http://localhost']
+        card.fields.properties.property_url = 'http://localhost'
 
         const component = wrapDNDIntl(
             <PropertyValueElement
@@ -134,7 +111,7 @@ describe('components/propertyValueElement', () => {
             type: 'text',
             options: [],
         }
-        card.fields.properties.person = ['value1', 'value2']
+        card.fields.properties.person = 'value1'
 
         const component = wrapDNDIntl(
             <PropertyValueElement
@@ -157,7 +134,7 @@ describe('components/propertyValueElement', () => {
             type: 'date',
             options: [],
         }
-        card.fields.properties.date = ['invalid date']
+        card.fields.properties.date = 'invalid date'
 
         const component = wrapDNDIntl(
             <PropertyValueElement
diff --git a/webapp/src/components/propertyValueElement.tsx b/webapp/src/components/propertyValueElement.tsx
index 2109ca5bd..c14640715 100644
--- a/webapp/src/components/propertyValueElement.tsx
+++ b/webapp/src/components/propertyValueElement.tsx
@@ -1,27 +1,12 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
 
-import React, {useState, useCallback, useEffect, useRef} from 'react'
-import {useIntl} from 'react-intl'
+import React from 'react'
 
-import {Board, IPropertyOption, IPropertyTemplate, PropertyType} from '../blocks/board'
+import {Board, IPropertyTemplate} from '../blocks/board'
 import {Card} from '../blocks/card'
-import mutator from '../mutator'
-import {OctoUtils} from '../octoUtils'
-import {Utils, IDType} from '../utils'
-import Editable from '../widgets/editable'
-import Switch from '../widgets/switch'
 
-import UserProperty from './properties/user/user'
-import MultiSelectProperty from './properties/multiSelect/multiSelect'
-import URLProperty from './properties/link/link'
-import LastModifiedBy from './properties/lastModifiedBy/lastModifiedBy'
-import LastModifiedAt from './properties/lastModifiedAt/lastModifiedAt'
-import CreatedAt from './properties/createdAt/createdAt'
-import CreatedBy from './properties/createdBy/createdBy'
-import DateRange from './properties/dateRange/dateRange'
-import SelectProperty from './properties/select/select'
-import {propertyValueClassName} from './propertyValueUtils'
+import propsRegistry from '../properties'
 
 type Props = {
     board: Board
@@ -32,228 +17,23 @@ type Props = {
 }
 
 const PropertyValueElement = (props:Props): JSX.Element => {
-    const [value, setValue] = useState(props.card.fields.properties[props.propertyTemplate.id] || '')
-    const [serverValue, setServerValue] = useState(props.card.fields.properties[props.propertyTemplate.id] || '')
-
     const {card, propertyTemplate, readOnly, showEmptyPlaceholder, board} = props
 
-    const intl = useIntl()
     const propertyValue = card.fields.properties[propertyTemplate.id]
-    const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, propertyTemplate, intl)
-    const emptyDisplayValue = showEmptyPlaceholder ? intl.formatMessage({id: 'PropertyValueElement.empty', defaultMessage: 'Empty'}) : ''
-    const finalDisplayValue = displayValue || emptyDisplayValue
 
-    const editableFields: Array<PropertyType> = ['text', 'number', 'email', 'url', 'phone']
-
-    const saveTextProperty = useCallback(() => {
-        if (editableFields.includes(props.propertyTemplate.type)) {
-            if (value !== (props.card.fields.properties[props.propertyTemplate.id] || '')) {
-                mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, value)
-            }
-        }
-    }, [props.card, props.propertyTemplate, value])
-
-    const saveTextPropertyRef = useRef<() => void>(saveTextProperty)
-    saveTextPropertyRef.current = saveTextProperty
-
-    useEffect(() => {
-        if (serverValue === value) {
-            setValue(props.card.fields.properties[props.propertyTemplate.id] || '')
-        }
-        setServerValue(props.card.fields.properties[props.propertyTemplate.id] || '')
-    }, [value, props.card.fields.properties[props.propertyTemplate.id]])
-
-    useEffect(() => {
-        return () => {
-            saveTextPropertyRef.current && saveTextPropertyRef.current()
-        }
-    }, [])
-
-    const onDeleteValue = useCallback(() => mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, ''), [card, propertyTemplate.id])
-    const onDeleteValueInMultiselect = useCallback((valueToDelete: IPropertyOption, currentValues: IPropertyOption[]) => {
-        const newValues = currentValues.
-            filter((currentValue) => currentValue.id !== valueToDelete.id).
-            map((currentValue) => currentValue.id)
-        mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValues)
-    }, [props.board.id, card, propertyTemplate.id])
-    const onCreateValueInMultiselect = useCallback((newValue: string, currentValues: IPropertyOption[]) => {
-        const option: IPropertyOption = {
-            id: Utils.createGuid(IDType.BlockID),
-            value: newValue,
-            color: 'propColorDefault',
-        }
-        currentValues.push(option)
-        mutator.insertPropertyOption(board.id, board.cardProperties, propertyTemplate, option, 'add property option').then(() => {
-            mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, currentValues.map((v: IPropertyOption) => v.id))
-        })
-    }, [board, props.board.id, card, propertyTemplate])
-    const onChangeUser = useCallback((newValue) => mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValue), [props.board.id, card, propertyTemplate.id])
-    const onCancelEditable = useCallback(() => setValue(propertyValue || ''), [propertyValue])
-    const onChangeDateRange = useCallback((newValue) => {
-        if (value !== newValue) {
-            setValue(newValue)
-            mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValue)
-        }
-    }, [value, props.board.id, card, propertyTemplate.id])
-    const onChangeInMultiselect = useCallback((newValue) => mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValue), [props.board.id, card, propertyTemplate])
-    const onChangeColorInMultiselect = useCallback((option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(board.id, board.cardProperties, propertyTemplate, option, colorId), [board, propertyTemplate])
-    const onDeleteOptionInMultiselect = useCallback((option: IPropertyOption) => mutator.deletePropertyOption(board.id, board.cardProperties, propertyTemplate, option), [board, propertyTemplate])
-
-    const onCreateInSelect = useCallback((newValue) => {
-        const option: IPropertyOption = {
-            id: Utils.createGuid(IDType.BlockID),
-            value: newValue,
-            color: 'propColorDefault',
-        }
-        mutator.insertPropertyOption(board.id, board.cardProperties, propertyTemplate, option, 'add property option').then(() => {
-            mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, option.id)
-        })
-    }, [board, props.board.id, card, propertyTemplate.id])
-
-    const onChangeInSelect = useCallback((newValue) => mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValue), [props.board.id, card, propertyTemplate])
-    const onChangeColorInSelect = useCallback((option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(board.id, board.cardProperties, propertyTemplate, option, colorId), [board, propertyTemplate])
-    const onDeleteOptionInSelect = useCallback((option: IPropertyOption) => mutator.deletePropertyOption(board.id, board.cardProperties, propertyTemplate, option), [board, propertyTemplate])
-
-    const validateProp = useCallback((val: string): boolean => {
-        if (val === '') {
-            return true
-        }
-        switch (propertyTemplate.type) {
-        case 'number':
-            return !isNaN(parseInt(val, 10))
-        case 'email': {
-            const emailRegexp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
-            return emailRegexp.test(val)
-        }
-        case 'url': {
-            const urlRegexp = /(((.+:(?:\/\/)?)?(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/
-            return urlRegexp.test(val)
-        }
-        case 'text':
-            return true
-        case 'phone':
-            return true
-        default:
-            return false
-        }
-    }, [propertyTemplate.type])
-
-    if (propertyTemplate.type === 'multiSelect') {
-        return (
-            <MultiSelectProperty
-                isEditable={!readOnly && Boolean(board)}
-                emptyValue={emptyDisplayValue}
-                propertyTemplate={propertyTemplate}
-                propertyValue={propertyValue}
-                onChange={onChangeInMultiselect}
-                onChangeColor={onChangeColorInMultiselect}
-                onDeleteOption={onDeleteOptionInMultiselect}
-                onCreate={onCreateValueInMultiselect}
-                onDeleteValue={onDeleteValueInMultiselect}
-            />
-        )
-    }
-
-    if (propertyTemplate.type === 'select') {
-        return (
-            <SelectProperty
-                isEditable={!readOnly && Boolean(board)}
-                emptyValue={emptyDisplayValue}
-                propertyValue={propertyValue as string}
-                propertyTemplate={propertyTemplate}
-                onCreate={onCreateInSelect}
-                onChange={onChangeInSelect}
-                onChangeColor={onChangeColorInSelect}
-                onDeleteOption={onDeleteOptionInSelect}
-                onDeleteValue={onDeleteValue}
-            />
-        )
-    } else if (propertyTemplate.type === 'person') {
-        return (
-            <UserProperty
-                value={propertyValue?.toString()}
-                readonly={readOnly}
-                onChange={onChangeUser}
-            />
-        )
-    } else if (propertyTemplate.type === 'date') {
-        const className = propertyValueClassName({readonly: readOnly})
-        if (readOnly) {
-            return <div className={className}>{displayValue}</div>
-        }
-        return (
-            <DateRange
-                className={className}
-                value={value.toString()}
-                showEmptyPlaceholder={showEmptyPlaceholder}
-                onChange={onChangeDateRange}
-            />
-        )
-    } else if (propertyTemplate.type === 'url') {
-        return (
-            <URLProperty
-                value={value.toString()}
-                readonly={readOnly}
-                placeholder={emptyDisplayValue}
-                onChange={setValue}
-                onSave={saveTextProperty}
-                onCancel={() => setValue(propertyValue || '')}
-                validator={validateProp}
-            />
-        )
-    } else if (propertyTemplate.type === 'checkbox') {
-        return (
-            <Switch
-                isOn={Boolean(propertyValue)}
-                onChanged={(newBool) => {
-                    const newValue = newBool ? 'true' : ''
-                    mutator.changePropertyValue(props.board.id, card, propertyTemplate.id, newValue)
-                }}
-                readOnly={readOnly}
-            />
-        )
-    } else if (propertyTemplate.type === 'createdBy') {
-        return (
-            <CreatedBy userID={card.createdBy}/>
-        )
-    } else if (propertyTemplate.type === 'updatedBy') {
-        return (
-            <LastModifiedBy
-                card={card}
-                board={board}
-            />
-        )
-    } else if (propertyTemplate.type === 'createdTime') {
-        return (
-            <CreatedAt createAt={card.createAt}/>
-        )
-    } else if (propertyTemplate.type === 'updatedTime') {
-        return (
-            <LastModifiedAt card={card}/>
-        )
-    }
-
-    if (
-        editableFields.includes(propertyTemplate.type)
-    ) {
-        if (!readOnly) {
-            return (
-                <Editable
-                    className={propertyValueClassName()}
-                    placeholderText={emptyDisplayValue}
-                    value={value.toString()}
-                    autoExpand={true}
-                    onChange={setValue}
-                    onSave={saveTextProperty}
-                    onCancel={onCancelEditable}
-                    validator={validateProp}
-                    spellCheck={propertyTemplate.type === 'text'}
-                />
-            )
-        }
-        return <div className={propertyValueClassName({readonly: true})}>{displayValue}</div>
-    }
-    return <div className={propertyValueClassName()}>{finalDisplayValue}</div>
+    const property = propsRegistry.get(propertyTemplate.type)
+    const Editor = property.Editor
+    return (
+        <Editor
+            property={property}
+            card={card}
+            board={board}
+            readOnly={readOnly}
+            showEmptyPlaceholder={showEmptyPlaceholder}
+            propertyTemplate={propertyTemplate}
+            propertyValue={propertyValue}
+        />
+    )
 }
 
 export default PropertyValueElement
diff --git a/webapp/src/components/propertyValueUtils.ts b/webapp/src/components/propertyValueUtils.ts
deleted file mode 100644
index dbdd29340..000000000
--- a/webapp/src/components/propertyValueUtils.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-export function propertyValueClassName(options: { readonly?: boolean } = {}): string {
-    return `octo-propertyvalue${options.readonly ? ' octo-propertyvalue--readonly' : ''}`
-}
diff --git a/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap b/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap
index f3d6daa3e..8529104d1 100644
--- a/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap
+++ b/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap
@@ -232,7 +232,7 @@ exports[`src/components/shareBoard/shareBoard confirm unlinking linked channel 1
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -441,7 +441,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -650,7 +650,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -1118,7 +1118,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -1586,7 +1586,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -1822,7 +1822,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -2290,7 +2290,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -3469,7 +3469,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -3678,7 +3678,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -3887,7 +3887,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
@@ -4096,7 +4096,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
               </a>
             </div>
             <button
-              title="Copy internal link"
+              title="Copy link"
               type="button"
             >
               <i
diff --git a/webapp/src/components/shareBoard/shareBoard.test.tsx b/webapp/src/components/shareBoard/shareBoard.test.tsx
index 8d2061384..1ad674fdc 100644
--- a/webapp/src/components/shareBoard/shareBoard.test.tsx
+++ b/webapp/src/components/shareBoard/shareBoard.test.tsx
@@ -252,7 +252,7 @@ describe('src/components/shareBoard/shareBoard', () => {
             )
             container = result.container
         })
-        const copyLinkElement = screen.getByTitle('Copy internal link')
+        const copyLinkElement = screen.getByTitle('Copy link')
         expect(copyLinkElement).toBeDefined()
 
         expect(container).toMatchSnapshot()
@@ -283,10 +283,10 @@ describe('src/components/shareBoard/shareBoard', () => {
 
         expect(container).toMatchSnapshot()
 
-        const copyLinkElement = screen.getByTitle('Copy internal link')
+        const copyLinkElement = screen.getByTitle('Copy link')
         expect(copyLinkElement).toBeDefined()
 
-        await act(async () => {
+        act(() => {
             userEvent.click(copyLinkElement!)
         })
 
@@ -295,7 +295,6 @@ describe('src/components/shareBoard/shareBoard', () => {
 
         const copiedLinkElement = screen.getByText('Copied!')
         expect(copiedLinkElement).toBeDefined()
-        expect(copiedLinkElement.textContent).toContain('Copied!')
     })
 
     test('return shareBoard and click Regenerate token', async () => {
diff --git a/webapp/src/components/shareBoard/shareBoard.tsx b/webapp/src/components/shareBoard/shareBoard.tsx
index 5ef90faba..5c7a1fe66 100644
--- a/webapp/src/components/shareBoard/shareBoard.tsx
+++ b/webapp/src/components/shareBoard/shareBoard.tsx
@@ -547,7 +547,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
                         <Button
                             emphasis='secondary'
                             size='medium'
-                            title='Copy internal link'
+                            title={intl.formatMessage({id: 'ShareBoard.copyLink', defaultMessage: 'Copy link'})}
                             onClick={() => {
                                 TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareLinkInternalCopy, {board: boardId})
                                 Utils.copyTextToClipboard(boardUrl.toString())
diff --git a/webapp/src/components/table/__snapshots__/table.test.tsx.snap b/webapp/src/components/table/__snapshots__/table.test.tsx.snap
index 900427472..aff88488f 100644
--- a/webapp/src/components/table/__snapshots__/table.test.tsx.snap
+++ b/webapp/src/components/table/__snapshots__/table.test.tsx.snap
@@ -251,7 +251,7 @@ exports[`components/table/Table extended should match snapshot with CreatedAt 1`
             style="width: 100px;"
           >
             <div
-              class="CreatedAt octo-propertyvalue octo-propertyvalue--readonly"
+              class="CreatedTime octo-propertyvalue octo-propertyvalue--readonly"
             >
               June 15, 2021, 4:22 PM
             </div>
@@ -377,7 +377,7 @@ exports[`components/table/Table extended should match snapshot with CreatedAt 1`
             style="width: 100px;"
           >
             <div
-              class="CreatedAt octo-propertyvalue octo-propertyvalue--readonly"
+              class="CreatedTime octo-propertyvalue octo-propertyvalue--readonly"
             >
               June 15, 2021, 4:22 PM
             </div>
@@ -711,10 +711,10 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
             style="width: 100px;"
           >
             <div
-              class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
+              class="Person octo-propertyvalue octo-propertyvalue--readonly"
             >
               <div
-                class="UserProperty-item"
+                class="Person-item"
               >
                 username_1
               </div>
@@ -841,10 +841,10 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
             style="width: 100px;"
           >
             <div
-              class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
+              class="Person octo-propertyvalue octo-propertyvalue--readonly"
             >
               <div
-                class="UserProperty-item"
+                class="Person-item"
               >
                 username_2
               </div>
@@ -1179,7 +1179,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
             style="width: 100px;"
           >
             <div
-              class="LastModifiedAt octo-propertyvalue octo-propertyvalue--readonly"
+              class="UpdatedTime octo-propertyvalue octo-propertyvalue--readonly"
             >
               June 20, 2021, 12:22 PM
             </div>
@@ -1305,7 +1305,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
             style="width: 100px;"
           >
             <div
-              class="LastModifiedAt octo-propertyvalue octo-propertyvalue--readonly"
+              class="UpdatedTime octo-propertyvalue octo-propertyvalue--readonly"
             >
               June 22, 2021, 11:23 AM
             </div>
@@ -1639,10 +1639,10 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
             style="width: 100px;"
           >
             <div
-              class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
+              class="Person octo-propertyvalue octo-propertyvalue--readonly"
             >
               <div
-                class="UserProperty-item"
+                class="Person-item"
               >
                 username_4
               </div>
@@ -1769,10 +1769,10 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
             style="width: 100px;"
           >
             <div
-              class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
+              class="Person octo-propertyvalue octo-propertyvalue--readonly"
             >
               <div
-                class="UserProperty-item"
+                class="Person-item"
               >
                 username_3
               </div>
diff --git a/webapp/src/components/table/tableHeaders.tsx b/webapp/src/components/table/tableHeaders.tsx
index 36912948c..027ee0b77 100644
--- a/webapp/src/components/table/tableHeaders.tsx
+++ b/webapp/src/components/table/tableHeaders.tsx
@@ -10,8 +10,7 @@ import {Card} from '../../blocks/card'
 import {Constants} from '../../constants'
 import mutator from '../../mutator'
 import {Utils} from '../../utils'
-
-import {OctoUtils} from '../../octoUtils'
+import propsRegistry from '../../properties'
 
 import './table.scss'
 
@@ -73,26 +72,8 @@ const TableHeaders = (props: Props): JSX.Element => {
             if (columnID === Constants.titleColumnId) {
                 thisLen = Utils.getTextWidth(card.title, columnFontPadding.fontDescriptor) + columnFontPadding.padding
             } else if (template) {
-                const displayValue = (OctoUtils.propertyDisplayValue(card, card.fields.properties[columnID], template as IPropertyTemplate, intl) || '')
-                switch (template.type) {
-                case 'select': {
-                    thisLen = Utils.getTextWidth(displayValue.toString().toUpperCase(), columnFontPadding.fontDescriptor)
-                    break
-                }
-                case 'multiSelect': {
-                    if (displayValue) {
-                        const displayValues = displayValue as string[]
-                        displayValues.forEach((value) => {
-                            thisLen += Utils.getTextWidth(value.toUpperCase(), columnFontPadding.fontDescriptor) + perItemPadding
-                        })
-                    }
-                    break
-                }
-                default: {
-                    thisLen = Utils.getTextWidth(displayValue.toString(), columnFontPadding.fontDescriptor)
-                    break
-                }
-                }
+                const property = propsRegistry.get(template.type)
+                property.valueLength(card.fields.properties[columnID], card, template as IPropertyTemplate, intl, columnFontPadding.fontDescriptor, perItemPadding)
                 thisLen += columnFontPadding.padding
             }
             if (thisLen > longestSize) {
diff --git a/webapp/src/components/viewHeader/__snapshots__/filterComponent.test.tsx.snap b/webapp/src/components/viewHeader/__snapshots__/filterComponent.test.tsx.snap
index 496884e6a..2c2f45904 100644
--- a/webapp/src/components/viewHeader/__snapshots__/filterComponent.test.tsx.snap
+++ b/webapp/src/components/viewHeader/__snapshots__/filterComponent.test.tsx.snap
@@ -35,7 +35,7 @@ exports[`components/viewHeader/filterComponent return filterComponent 1`] = `
             type="button"
           >
             <span>
-              (unknown)
+              Status
             </span>
           </button>
           <div
@@ -47,6 +47,29 @@ exports[`components/viewHeader/filterComponent return filterComponent 1`] = `
               <div
                 class="menu-options"
               >
+                <div>
+                  <div
+                    aria-label="Title"
+                    class="MenuOption TextOption menu-option"
+                    role="button"
+                  >
+                    <div
+                      class="d-flex"
+                    >
+                      <div
+                        class="noicon"
+                      />
+                    </div>
+                    <div
+                      class="menu-name"
+                    >
+                      Title
+                    </div>
+                    <div
+                      class="noicon"
+                    />
+                  </div>
+                </div>
                 <div>
                   <div
                     aria-label="Status"
@@ -185,6 +208,20 @@ exports[`components/viewHeader/filterComponent return filterComponent 1`] = `
             </span>
           </button>
         </div>
+        <div
+          aria-label="menuwrapper"
+          class="MenuWrapper filterValue"
+          role="button"
+        >
+          <button
+            class="Button"
+            type="button"
+          >
+            <span>
+              Status
+            </span>
+          </button>
+        </div>
         <div
           class="octo-spacer"
         />
@@ -246,7 +283,7 @@ exports[`components/viewHeader/filterComponent return filterComponent and add Fi
             type="button"
           >
             <span>
-              (unknown)
+              Status
             </span>
           </button>
           <div
@@ -258,6 +295,29 @@ exports[`components/viewHeader/filterComponent return filterComponent and add Fi
               <div
                 class="menu-options"
               >
+                <div>
+                  <div
+                    aria-label="Title"
+                    class="MenuOption TextOption menu-option"
+                    role="button"
+                  >
+                    <div
+                      class="d-flex"
+                    >
+                      <div
+                        class="noicon"
+                      />
+                    </div>
+                    <div
+                      class="menu-name"
+                    >
+                      Title
+                    </div>
+                    <div
+                      class="noicon"
+                    />
+                  </div>
+                </div>
                 <div>
                   <div
                     aria-label="Status"
@@ -396,6 +456,20 @@ exports[`components/viewHeader/filterComponent return filterComponent and add Fi
             </span>
           </button>
         </div>
+        <div
+          aria-label="menuwrapper"
+          class="MenuWrapper filterValue"
+          role="button"
+        >
+          <button
+            class="Button"
+            type="button"
+          >
+            <span>
+              Status
+            </span>
+          </button>
+        </div>
         <div
           class="octo-spacer"
         />
@@ -457,7 +531,7 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
             type="button"
           >
             <span>
-              (unknown)
+              Status
             </span>
           </button>
         </div>
@@ -505,8 +579,6 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
                       class="noicon"
                     />
                   </div>
-                </div>
-                <div>
                   <div
                     aria-label="doesn't include"
                     class="MenuOption TextOption menu-option"
@@ -528,8 +600,6 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
                       class="noicon"
                     />
                   </div>
-                </div>
-                <div>
                   <div
                     aria-label="is empty"
                     class="MenuOption TextOption menu-option"
@@ -551,8 +621,6 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
                       class="noicon"
                     />
                   </div>
-                </div>
-                <div>
                   <div
                     aria-label="is not empty"
                     class="MenuOption TextOption menu-option"
@@ -575,6 +643,8 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
                     />
                   </div>
                 </div>
+                <div />
+                <div />
               </div>
               <div
                 class="menu-spacer hideOnWidescreen"
@@ -607,6 +677,20 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
             </div>
           </div>
         </div>
+        <div
+          aria-label="menuwrapper"
+          class="MenuWrapper filterValue"
+          role="button"
+        >
+          <button
+            class="Button"
+            type="button"
+          >
+            <span>
+              Status
+            </span>
+          </button>
+        </div>
         <div
           class="octo-spacer"
         />
@@ -680,6 +764,29 @@ exports[`components/viewHeader/filterComponent return filterComponent and filter
               <div
                 class="menu-options"
               >
+                <div>
+                  <div
+                    aria-label="Title"
+                    class="MenuOption TextOption menu-option"
+                    role="button"
+                  >
+                    <div
+                      class="d-flex"
+                    >
+                      <div
+                        class="noicon"
+                      />
+                    </div>
+                    <div
+                      class="menu-name"
+                    >
+                      Title
+                    </div>
+                    <div
+                      class="noicon"
+                    />
+                  </div>
+                </div>
                 <div>
                   <div
                     aria-label="Status"
diff --git a/webapp/src/components/viewHeader/__snapshots__/filterEntry.test.tsx.snap b/webapp/src/components/viewHeader/__snapshots__/filterEntry.test.tsx.snap
index 0a817cfeb..f4d939817 100644
--- a/webapp/src/components/viewHeader/__snapshots__/filterEntry.test.tsx.snap
+++ b/webapp/src/components/viewHeader/__snapshots__/filterEntry.test.tsx.snap
@@ -15,7 +15,7 @@ exports[`components/viewHeader/filterEntry return filterEntry 1`] = `
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
       <div
@@ -27,6 +27,29 @@ exports[`components/viewHeader/filterEntry return filterEntry 1`] = `
           <div
             class="menu-options"
           >
+            <div>
+              <div
+                aria-label="Title"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  Title
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+            </div>
             <div>
               <div
                 aria-label="Status"
@@ -165,6 +188,20 @@ exports[`components/viewHeader/filterEntry return filterEntry 1`] = `
         </span>
       </button>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -195,7 +232,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
     </div>
@@ -243,8 +280,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="doesn't include"
                 class="MenuOption TextOption menu-option"
@@ -266,8 +301,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is empty"
                 class="MenuOption TextOption menu-option"
@@ -289,8 +322,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is not empty"
                 class="MenuOption TextOption menu-option"
@@ -313,6 +344,8 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
                 />
               </div>
             </div>
+            <div />
+            <div />
           </div>
           <div
             class="menu-spacer hideOnWidescreen"
@@ -345,6 +378,20 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on delet
         </div>
       </div>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -375,7 +422,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
     </div>
@@ -423,8 +470,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="doesn't include"
                 class="MenuOption TextOption menu-option"
@@ -446,8 +491,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is empty"
                 class="MenuOption TextOption menu-option"
@@ -469,8 +512,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is not empty"
                 class="MenuOption TextOption menu-option"
@@ -493,6 +534,8 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
                 />
               </div>
             </div>
+            <div />
+            <div />
           </div>
           <div
             class="menu-spacer hideOnWidescreen"
@@ -525,6 +568,20 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on doesn
         </div>
       </div>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -555,7 +612,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
     </div>
@@ -603,8 +660,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="doesn't include"
                 class="MenuOption TextOption menu-option"
@@ -626,8 +681,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is empty"
                 class="MenuOption TextOption menu-option"
@@ -649,8 +702,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is not empty"
                 class="MenuOption TextOption menu-option"
@@ -673,6 +724,8 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
                 />
               </div>
             </div>
+            <div />
+            <div />
           </div>
           <div
             class="menu-spacer hideOnWidescreen"
@@ -705,6 +758,20 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on inclu
         </div>
       </div>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -735,7 +802,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
     </div>
@@ -783,8 +850,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="doesn't include"
                 class="MenuOption TextOption menu-option"
@@ -806,8 +871,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is empty"
                 class="MenuOption TextOption menu-option"
@@ -829,8 +892,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is not empty"
                 class="MenuOption TextOption menu-option"
@@ -853,6 +914,8 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
                 />
               </div>
             </div>
+            <div />
+            <div />
           </div>
           <div
             class="menu-spacer hideOnWidescreen"
@@ -885,6 +948,20 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is em
         </div>
       </div>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -915,7 +992,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
         type="button"
       >
         <span>
-          (unknown)
+          Status
         </span>
       </button>
     </div>
@@ -963,8 +1040,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="doesn't include"
                 class="MenuOption TextOption menu-option"
@@ -986,8 +1061,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is empty"
                 class="MenuOption TextOption menu-option"
@@ -1009,8 +1082,6 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
                   class="noicon"
                 />
               </div>
-            </div>
-            <div>
               <div
                 aria-label="is not empty"
                 class="MenuOption TextOption menu-option"
@@ -1033,6 +1104,8 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
                 />
               </div>
             </div>
+            <div />
+            <div />
           </div>
           <div
             class="menu-spacer hideOnWidescreen"
@@ -1065,6 +1138,20 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on is no
         </div>
       </div>
     </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper filterValue"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Status
+        </span>
+      </button>
+    </div>
     <div
       class="octo-spacer"
     />
@@ -1107,6 +1194,29 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on statu
           <div
             class="menu-options"
           >
+            <div>
+              <div
+                aria-label="Title"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  Title
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+            </div>
             <div>
               <div
                 aria-label="Status"
@@ -1259,3 +1369,484 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on statu
   </div>
 </div>
 `;
+
+exports[`components/viewHeader/filterEntry return filterEntry for boolean field 1`] = `
+<div>
+  <div
+    class="FilterEntry"
+  >
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Property 1
+        </span>
+      </button>
+    </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          is set
+        </span>
+      </button>
+    </div>
+    <div
+      class="octo-spacer"
+    />
+    <button
+      class="Button"
+      type="button"
+    >
+      <span>
+        Delete
+      </span>
+    </button>
+  </div>
+</div>
+`;
+
+exports[`components/viewHeader/filterEntry return filterEntry for boolean field 2`] = `
+<div>
+  <div
+    class="FilterEntry"
+  >
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Property 1
+        </span>
+      </button>
+    </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          is set
+        </span>
+      </button>
+      <div
+        class="Menu noselect bottom "
+      >
+        <div
+          class="menu-contents"
+        >
+          <div
+            class="menu-options"
+          >
+            <div />
+            <div>
+              <div
+                aria-label="is set"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  is set
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="is not set"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  is not set
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+            </div>
+            <div />
+          </div>
+          <div
+            class="menu-spacer hideOnWidescreen"
+          />
+          <div
+            class="menu-options hideOnWidescreen"
+          >
+            <div
+              aria-label="Cancel"
+              class="MenuOption TextOption menu-option menu-cancel"
+              role="button"
+            >
+              <div
+                class="d-flex"
+              >
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                class="menu-name"
+              >
+                Cancel
+              </div>
+              <div
+                class="noicon"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="octo-spacer"
+    />
+    <button
+      class="Button"
+      type="button"
+    >
+      <span>
+        Delete
+      </span>
+    </button>
+  </div>
+</div>
+`;
+
+exports[`components/viewHeader/filterEntry return filterEntry for text field 1`] = `
+<div>
+  <div
+    class="FilterEntry"
+  >
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Property 2
+        </span>
+      </button>
+    </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          contains
+        </span>
+      </button>
+    </div>
+    <input
+      class="Editable "
+      placeholder="filter text"
+      title=""
+      value=""
+    />
+    <div
+      class="octo-spacer"
+    />
+    <button
+      class="Button"
+      type="button"
+    >
+      <span>
+        Delete
+      </span>
+    </button>
+  </div>
+</div>
+`;
+
+exports[`components/viewHeader/filterEntry return filterEntry for text field 2`] = `
+<div>
+  <div
+    class="FilterEntry"
+  >
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          Property 2
+        </span>
+      </button>
+    </div>
+    <div
+      aria-label="menuwrapper"
+      class="MenuWrapper"
+      role="button"
+    >
+      <button
+        class="Button"
+        type="button"
+      >
+        <span>
+          contains
+        </span>
+      </button>
+      <div
+        class="Menu noselect bottom "
+      >
+        <div
+          class="menu-contents"
+        >
+          <div
+            class="menu-options"
+          >
+            <div />
+            <div />
+            <div>
+              <div
+                aria-label="is"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  is
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="contains"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  contains
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="doesn't contain"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  doesn't contain
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="starts with"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  starts with
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="doesn't start with"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  doesn't start with
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="ends with"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  ends with
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                aria-label="doesn't end with"
+                class="MenuOption TextOption menu-option"
+                role="button"
+              >
+                <div
+                  class="d-flex"
+                >
+                  <div
+                    class="noicon"
+                  />
+                </div>
+                <div
+                  class="menu-name"
+                >
+                  doesn't end with
+                </div>
+                <div
+                  class="noicon"
+                />
+              </div>
+            </div>
+          </div>
+          <div
+            class="menu-spacer hideOnWidescreen"
+          />
+          <div
+            class="menu-options hideOnWidescreen"
+          >
+            <div
+              aria-label="Cancel"
+              class="MenuOption TextOption menu-option menu-cancel"
+              role="button"
+            >
+              <div
+                class="d-flex"
+              >
+                <div
+                  class="noicon"
+                />
+              </div>
+              <div
+                class="menu-name"
+              >
+                Cancel
+              </div>
+              <div
+                class="noicon"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <input
+      class="Editable "
+      placeholder="filter text"
+      title=""
+      value=""
+    />
+    <div
+      class="octo-spacer"
+    />
+    <button
+      class="Button"
+      type="button"
+    >
+      <span>
+        Delete
+      </span>
+    </button>
+  </div>
+</div>
+`;
diff --git a/webapp/src/components/viewHeader/filterComponent.test.tsx b/webapp/src/components/viewHeader/filterComponent.test.tsx
index 7b2cbe3c3..7795ccf6c 100644
--- a/webapp/src/components/viewHeader/filterComponent.test.tsx
+++ b/webapp/src/components/viewHeader/filterComponent.test.tsx
@@ -21,14 +21,21 @@ import FilterComponenet from './filterComponent'
 
 jest.mock('../../mutator')
 const mockedMutator = mocked(mutator, true)
-const filter: FilterClause = {
-    propertyId: '1',
-    condition: 'includes',
-    values: ['Status'],
-}
 
 const board = TestBlockFactory.createBoard()
 const activeView = TestBlockFactory.createBoardView(board)
+
+const filter: FilterClause = {
+    propertyId: board.cardProperties[0].id,
+    condition: 'includes',
+    values: ['Status'],
+}
+const unknownFilter: FilterClause = {
+    propertyId: 'unknown',
+    condition: 'includes',
+    values: [],
+}
+
 const state = {
     users: {
         me: {
@@ -81,6 +88,7 @@ describe('components/viewHeader/filterComponent', () => {
     })
 
     test('return filterComponent and filter by status', () => {
+        activeView.fields.filter.filters = [unknownFilter]
         const {container} = render(
             wrapIntl(
                 <ReduxProvider store={store}>
diff --git a/webapp/src/components/viewHeader/filterComponent.tsx b/webapp/src/components/viewHeader/filterComponent.tsx
index 04cc1929f..10ed9815b 100644
--- a/webapp/src/components/viewHeader/filterComponent.tsx
+++ b/webapp/src/components/viewHeader/filterComponent.tsx
@@ -10,6 +10,7 @@ import {BoardView} from '../../blocks/boardView'
 import mutator from '../../mutator'
 import {Utils} from '../../utils'
 import Button from '../../widgets/buttons/button'
+import propsRegistry from '../../properties'
 
 import Modal from '../modal'
 
@@ -47,10 +48,10 @@ const FilterComponent = (props: Props): JSX.Element => {
         const filterGroup = createFilterGroup(activeView.fields.filter)
         const filter = createFilterClause()
 
-        // Pick the first select property that isn't already filtered on
+        // Pick the first filterable property that isn't already filtered on
         const selectProperty = board.cardProperties.
             filter((o: IPropertyTemplate) => !filters.find((f) => f.propertyId === o.id)).
-            find((o: IPropertyTemplate) => o.type === 'select' || o.type === 'multiSelect')
+            find((o: IPropertyTemplate) => propsRegistry.get(o.type).canFilter)
         if (selectProperty) {
             filter.propertyId = selectProperty.id
         }
diff --git a/webapp/src/components/viewHeader/filterEntry.test.tsx b/webapp/src/components/viewHeader/filterEntry.test.tsx
index f215c9cac..ea4432d99 100644
--- a/webapp/src/components/viewHeader/filterEntry.test.tsx
+++ b/webapp/src/components/viewHeader/filterEntry.test.tsx
@@ -25,11 +25,28 @@ const mockedMutator = mocked(mutator, true)
 
 const board = TestBlockFactory.createBoard()
 const activeView = TestBlockFactory.createBoardView(board)
-const filter: FilterClause = {
-    propertyId: '1',
+board.cardProperties[1].type = 'checkbox'
+board.cardProperties[2].type = 'text'
+const statusFilter: FilterClause = {
+    propertyId: board.cardProperties[0].id,
     condition: 'includes',
     values: ['Status'],
 }
+const booleanFilter: FilterClause = {
+    propertyId: board.cardProperties[1].id,
+    condition: 'isSet',
+    values: [],
+}
+const textFilter: FilterClause = {
+    propertyId: board.cardProperties[2].id,
+    condition: 'contains',
+    values: [],
+}
+const unknownFilter: FilterClause = {
+    propertyId: 'unknown',
+    condition: 'includes',
+    values: [],
+}
 const state = {
     users: {
         me: {
@@ -45,7 +62,7 @@ describe('components/viewHeader/filterEntry', () => {
     beforeEach(() => {
         jest.clearAllMocks()
         board.cardProperties[0].options = [{id: 'Status', value: 'Status', color: ''}]
-        activeView.fields.filter.filters = [filter]
+        activeView.fields.filter.filters = [statusFilter]
     })
     test('return filterEntry', () => {
         const {container} = render(
@@ -55,7 +72,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -65,7 +82,8 @@ describe('components/viewHeader/filterEntry', () => {
         expect(container).toMatchSnapshot()
     })
 
-    test('return filterEntry and click on status', () => {
+    test('return filterEntry for boolean field', () => {
+        activeView.fields.filter.filters = [booleanFilter]
         const {container} = render(
             wrapIntl(
                 <ReduxProvider store={store}>
@@ -73,7 +91,47 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={booleanFilter}
+                    />
+                </ReduxProvider>,
+            ),
+        )
+        expect(container).toMatchSnapshot()
+        const buttonElement = screen.getAllByRole('button', {name: 'menuwrapper'})[1]
+        userEvent.click(buttonElement)
+        expect(container).toMatchSnapshot()
+    })
+
+    test('return filterEntry for text field', () => {
+        activeView.fields.filter.filters = [textFilter]
+        const {container} = render(
+            wrapIntl(
+                <ReduxProvider store={store}>
+                    <FilterEntry
+                        board={board}
+                        view={activeView}
+                        conditionClicked={mockedConditionClicked}
+                        filter={textFilter}
+                    />
+                </ReduxProvider>,
+            ),
+        )
+        expect(container).toMatchSnapshot()
+        const buttonElement = screen.getAllByRole('button', {name: 'menuwrapper'})[1]
+        userEvent.click(buttonElement)
+        expect(container).toMatchSnapshot()
+    })
+
+    test('return filterEntry and click on status', () => {
+        activeView.fields.filter.filters = [unknownFilter]
+        const {container} = render(
+            wrapIntl(
+                <ReduxProvider store={store}>
+                    <FilterEntry
+                        board={board}
+                        view={activeView}
+                        conditionClicked={mockedConditionClicked}
+                        filter={unknownFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -93,7 +151,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -113,7 +171,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -133,7 +191,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -153,7 +211,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
@@ -173,7 +231,7 @@ describe('components/viewHeader/filterEntry', () => {
                         board={board}
                         view={activeView}
                         conditionClicked={mockedConditionClicked}
-                        filter={filter}
+                        filter={statusFilter}
                     />
                 </ReduxProvider>,
             ),
diff --git a/webapp/src/components/viewHeader/filterEntry.tsx b/webapp/src/components/viewHeader/filterEntry.tsx
index 0de4f10c4..c127305b7 100644
--- a/webapp/src/components/viewHeader/filterEntry.tsx
+++ b/webapp/src/components/viewHeader/filterEntry.tsx
@@ -13,6 +13,7 @@ import {BoardView} from '../../blocks/boardView'
 import Button from '../../widgets/buttons/button'
 import Menu from '../../widgets/menu'
 import MenuWrapper from '../../widgets/menuWrapper'
+import propsRegistry from '../../properties'
 
 import FilterValue from './filterValue'
 
@@ -30,7 +31,12 @@ const FilterEntry = (props: Props): JSX.Element => {
     const intl = useIntl()
 
     const template = board.cardProperties.find((o: IPropertyTemplate) => o.id === filter.propertyId)
-    const propertyName = template ? template.name : '(unknown)'
+    let propertyType = propsRegistry.get(template?.type || 'unknown')
+    let propertyName = template ? template.name : '(unknown)'
+    if (filter.propertyId === 'title') {
+        propertyType = propsRegistry.get('text')
+        propertyName = 'Title'
+    }
     const key = `${filter.propertyId}-${filter.condition}}`
     return (
         <div
@@ -40,7 +46,24 @@ const FilterEntry = (props: Props): JSX.Element => {
             <MenuWrapper>
                 <Button>{propertyName}</Button>
                 <Menu>
-                    {board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select' || o.type === 'multiSelect').map((o: IPropertyTemplate) => (
+                    <Menu.Text
+                        key={'title'}
+                        id={'title'}
+                        name={'Title'}
+                        onClick={(optionId: string) => {
+                            const filterIndex = view.fields.filter.filters.indexOf(filter)
+                            Utils.assert(filterIndex >= 0, "Can't find filter")
+                            const filterGroup = createFilterGroup(view.fields.filter)
+                            const newFilter = filterGroup.filters[filterIndex] as FilterClause
+                            Utils.assert(newFilter, `No filter at index ${filterIndex}`)
+                            if (newFilter.propertyId !== optionId) {
+                                newFilter.propertyId = optionId
+                                newFilter.values = []
+                                mutator.changeViewFilter(props.board.id, view.id, view.fields.filter, filterGroup)
+                            }
+                        }}
+                    />
+                    {board.cardProperties.filter((o: IPropertyTemplate) => propsRegistry.get(o.type).canFilter).map((o: IPropertyTemplate) => (
                         <Menu.Text
                             key={o.id}
                             id={o.id}
@@ -63,34 +86,88 @@ const FilterEntry = (props: Props): JSX.Element => {
             <MenuWrapper>
                 <Button>{OctoUtils.filterConditionDisplayString(filter.condition, intl)}</Button>
                 <Menu>
-                    <Menu.Text
-                        id='includes'
-                        name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
-                        onClick={(id) => props.conditionClicked(id, filter)}
-                    />
-                    <Menu.Text
-                        id='notIncludes'
-                        name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
-                        onClick={(id) => props.conditionClicked(id, filter)}
-                    />
-                    <Menu.Text
-                        id='isEmpty'
-                        name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
-                        onClick={(id) => props.conditionClicked(id, filter)}
-                    />
-                    <Menu.Text
-                        id='isNotEmpty'
-                        name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
-                        onClick={(id) => props.conditionClicked(id, filter)}
-                    />
+                    {propertyType.filterValueType === 'options' &&
+                        <>
+                            <Menu.Text
+                                id='includes'
+                                name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='notIncludes'
+                                name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='isEmpty'
+                                name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='isNotEmpty'
+                                name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                        </>}
+                    {propertyType.filterValueType === 'boolean' &&
+                        <>
+                            <Menu.Text
+                                id='isSet'
+                                name={intl.formatMessage({id: 'Filter.is-set', defaultMessage: 'is set'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='isNotSet'
+                                name={intl.formatMessage({id: 'Filter.is-not-set', defaultMessage: 'is not set'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                        </>}
+                    {propertyType.filterValueType === 'text' &&
+                        <>
+                            <Menu.Text
+                                id='is'
+                                name={intl.formatMessage({id: 'Filter.contains', defaultMessage: 'is'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='contains'
+                                name={intl.formatMessage({id: 'Filter.contains', defaultMessage: 'contains'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='notContains'
+                                name={intl.formatMessage({id: 'Filter.not-contains', defaultMessage: 'doesn\'t contain'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='startsWith'
+                                name={intl.formatMessage({id: 'Filter.starts-with', defaultMessage: 'starts with'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='notStartsWith'
+                                name={intl.formatMessage({id: 'Filter.not-starts-with', defaultMessage: 'doesn\'t start with'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='endsWith'
+                                name={intl.formatMessage({id: 'Filter.ends-with', defaultMessage: 'ends with'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                            <Menu.Text
+                                id='notEndsWith'
+                                name={intl.formatMessage({id: 'Filter.not-ends-with', defaultMessage: 'doesn\'t end with'})}
+                                onClick={(id) => props.conditionClicked(id, filter)}
+                            />
+                        </>}
                 </Menu>
             </MenuWrapper>
-            {template &&
-                <FilterValue
-                    filter={filter}
-                    template={template}
-                    view={view}
-                />}
+            <FilterValue
+                filter={filter}
+                template={template}
+                view={view}
+                propertyType={propertyType}
+            />
             <div className='octo-spacer'/>
             <Button
                 onClick={() => {
diff --git a/webapp/src/components/viewHeader/filterValue.test.tsx b/webapp/src/components/viewHeader/filterValue.test.tsx
index ed3a00bff..d3a0c8efe 100644
--- a/webapp/src/components/viewHeader/filterValue.test.tsx
+++ b/webapp/src/components/viewHeader/filterValue.test.tsx
@@ -17,6 +17,7 @@ import {TestBlockFactory} from '../../test/testBlockFactory'
 import {wrapIntl, mockStateStore} from '../../testUtils'
 
 import mutator from '../../mutator'
+import propsRegistry from '../../properties'
 
 import FilterValue from './filterValue'
 
@@ -54,6 +55,7 @@ describe('components/viewHeader/filterValue', () => {
                         view={activeView}
                         filter={filter}
                         template={board.cardProperties[0]}
+                        propertyType={propsRegistry.get(board.cardProperties[0].type)}
                     />
                 </ReduxProvider>,
             ),
@@ -70,6 +72,7 @@ describe('components/viewHeader/filterValue', () => {
                         view={activeView}
                         filter={filter}
                         template={board.cardProperties[0]}
+                        propertyType={propsRegistry.get(board.cardProperties[0].type)}
                     />
                 </ReduxProvider>,
             ),
@@ -91,6 +94,7 @@ describe('components/viewHeader/filterValue', () => {
                         view={activeView}
                         filter={filter}
                         template={board.cardProperties[0]}
+                        propertyType={propsRegistry.get(board.cardProperties[0].type)}
                     />
                 </ReduxProvider>,
             ),
@@ -112,6 +116,7 @@ describe('components/viewHeader/filterValue', () => {
                         view={activeView}
                         filter={filter}
                         template={board.cardProperties[0]}
+                        propertyType={propsRegistry.get(board.cardProperties[0].type)}
                     />
                 </ReduxProvider>,
             ),
diff --git a/webapp/src/components/viewHeader/filterValue.tsx b/webapp/src/components/viewHeader/filterValue.tsx
index ee75ad5e8..440165220 100644
--- a/webapp/src/components/viewHeader/filterValue.tsx
+++ b/webapp/src/components/viewHeader/filterValue.tsx
@@ -1,6 +1,7 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
-import React from 'react'
+import React, {useState} from 'react'
+import {useIntl} from 'react-intl'
 
 import {IPropertyTemplate} from '../../blocks/board'
 import {FilterClause} from '../../blocks/filterClause'
@@ -10,26 +11,61 @@ import mutator from '../../mutator'
 import {Utils} from '../../utils'
 import Button from '../../widgets/buttons/button'
 import Menu from '../../widgets/menu'
+import Editable from '../../widgets/editable'
 import MenuWrapper from '../../widgets/menuWrapper'
+import {PropertyType} from '../../properties/types'
 
 import './filterValue.scss'
 
 type Props = {
     view: BoardView
     filter: FilterClause
-    template: IPropertyTemplate
+    template?: IPropertyTemplate
+    propertyType: PropertyType
 }
 
 const filterValue = (props: Props): JSX.Element|null => {
-    const {filter, template, view} = props
-    if (filter.condition !== 'includes' && filter.condition !== 'notIncludes') {
+    const {filter, template, view, propertyType} = props
+    const [value, setValue] = useState(filter.values.length > 0 ? filter.values[0] : '')
+    const intl = useIntl()
+
+    if (propertyType.filterValueType === 'none') {
         return null
     }
 
+    if (propertyType.filterValueType === 'boolean') {
+        return null
+    }
+
+    if (propertyType.filterValueType === 'options' && filter.condition !== 'includes' && filter.condition !== 'notIncludes') {
+        return null
+    }
+
+    if (propertyType.filterValueType === 'text') {
+        return (
+            <Editable
+                onChange={setValue}
+                value={value}
+                placeholderText={intl.formatMessage({id: 'FilterByText.placeholder', defaultMessage: 'filter text'})}
+                onSave={() => {
+                    const filterIndex = view.fields.filter.filters.indexOf(filter)
+                    Utils.assert(filterIndex >= 0, "Can't find filter")
+
+                    const filterGroup = createFilterGroup(view.fields.filter)
+                    const newFilter = filterGroup.filters[filterIndex] as FilterClause
+                    Utils.assert(newFilter, `No filter at index ${filterIndex}`)
+
+                    newFilter.values = [value]
+                    mutator.changeViewFilter(view.boardId, view.id, view.fields.filter, filterGroup)
+                }}
+            />
+        )
+    }
+
     let displayValue: string
     if (filter.values.length > 0) {
         displayValue = filter.values.map((id) => {
-            const option = template.options.find((o) => o.id === id)
+            const option = template?.options.find((o) => o.id === id)
             return option?.value || '(Unknown)'
         }).join(', ')
     } else {
@@ -40,7 +76,7 @@ const filterValue = (props: Props): JSX.Element|null => {
         <MenuWrapper className='filterValue'>
             <Button>{displayValue}</Button>
             <Menu>
-                {template.options.map((o) => (
+                {template?.options.map((o) => (
                     <Menu.Switch
                         key={o.id}
                         id={o.id}
diff --git a/webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx b/webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx
index b14fa68db..030f2e11b 100644
--- a/webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx
+++ b/webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx
@@ -11,7 +11,8 @@ import Button from '../../widgets/buttons/button'
 import Menu from '../../widgets/menu'
 import MenuWrapper from '../../widgets/menuWrapper'
 import CheckIcon from '../../widgets/icons/check'
-import {typeDisplayName} from '../../widgets/propertyMenu'
+
+import propsRegistry from '../../properties'
 
 type Props = {
     properties: readonly IPropertyTemplate[]
@@ -23,10 +24,10 @@ const ViewHeaderDisplayByMenu = (props: Props) => {
     const {properties, activeView, dateDisplayPropertyName} = props
     const intl = useIntl()
 
-    const createdDateName = typeDisplayName(intl, 'createdTime')
+    const createdDateName = propsRegistry.get('createdTime').displayName(intl)
 
     const getDateProperties = () : IPropertyTemplate[] => {
-        return properties?.filter((o: IPropertyTemplate) => o.type === 'date' || o.type === 'createdTime' || o.type === 'updatedTime')
+        return properties?.filter((o: IPropertyTemplate) => propsRegistry.get(o.type).isDate)
     }
 
     return (
diff --git a/webapp/src/components/viewHeader/viewHeaderGroupByMenu.tsx b/webapp/src/components/viewHeader/viewHeaderGroupByMenu.tsx
index d1ba3c70c..d93678388 100644
--- a/webapp/src/components/viewHeader/viewHeaderGroupByMenu.tsx
+++ b/webapp/src/components/viewHeader/viewHeaderGroupByMenu.tsx
@@ -16,6 +16,7 @@ import ShowIcon from '../../widgets/icons/show'
 import {useAppSelector} from '../../store/hooks'
 import {getCurrentViewCardsSortedFilteredAndGrouped} from '../../store/cards'
 import {getVisibleAndHiddenGroups} from '../../boardUtils'
+import propsRegistry from '../../properties'
 
 type Props = {
     properties: readonly IPropertyTemplate[]
@@ -97,7 +98,7 @@ const ViewHeaderGroupByMenu = (props: Props) => {
                         />
                         <Menu.Separator/>
                     </>}
-                {properties?.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
+                {properties?.filter((o: IPropertyTemplate) => propsRegistry.get(o.type).canGroup).map((option: IPropertyTemplate) => (
                     <Menu.Text
                         key={option.id}
                         id={option.id}
diff --git a/webapp/src/components/workspace.tsx b/webapp/src/components/workspace.tsx
index 812250471..7d28cdfd5 100644
--- a/webapp/src/components/workspace.tsx
+++ b/webapp/src/components/workspace.tsx
@@ -20,6 +20,7 @@ import {getClientConfig, setClientConfig} from '../store/clientConfig'
 import wsClient, {WSClient} from '../wsclient'
 import {ClientConfig} from '../config/clientConfig'
 import {Utils} from '../utils'
+import propsRegistry from '../properties'
 
 import {getMe} from "../store/users"
 
@@ -109,13 +110,13 @@ function CenterContent(props: Props) {
 
     if (board && !isBoardHidden() && activeView) {
         let property = groupByProperty
-        if ((!property || property.type !== 'select') && activeView.fields.viewType === 'board') {
-            property = board?.cardProperties.find((o) => o.type === 'select')
+        if ((!property || !propsRegistry.get(property.type).canGroup) && activeView.fields.viewType === 'board') {
+            property = board?.cardProperties.find((o) => propsRegistry.get(o.type).canGroup)
         }
 
         let displayProperty = dateDisplayProperty
         if (!displayProperty && activeView.fields.viewType === 'calendar') {
-            displayProperty = board.cardProperties.find((o) => o.type === 'date')
+            displayProperty = board.cardProperties.find((o) => propsRegistry.get(o.type).isDate)
         }
 
         return (
diff --git a/webapp/src/csvExporter.ts b/webapp/src/csvExporter.ts
index 5c5585a15..ff8dd8670 100644
--- a/webapp/src/csvExporter.ts
+++ b/webapp/src/csvExporter.ts
@@ -5,9 +5,9 @@ import {IntlShape} from 'react-intl'
 import {BoardView} from './blocks/boardView'
 import {Board, IPropertyTemplate} from './blocks/board'
 import {Card} from './blocks/card'
-import {OctoUtils} from './octoUtils'
 import {Utils} from './utils'
 import {IAppWindow} from './types'
+import propsRegistry from './properties'
 
 declare let window: IAppWindow
 const hashSignToken = '___hash_sign___'
@@ -79,17 +79,8 @@ class CsvExporter {
             row.push(`"${this.encodeText(card.title)}"`)
             visibleProperties.forEach((template: IPropertyTemplate) => {
                 const propertyValue = card.fields.properties[template.id]
-                const displayValue = (OctoUtils.propertyDisplayValue(card, propertyValue, template, intl) || '') as string
-                if (template.type === 'number') {
-                    const numericValue = propertyValue ? Number(propertyValue).toString() : ''
-                    row.push(numericValue)
-                } else if (template.type === 'multiSelect') {
-                    const multiSelectValue = ((displayValue as unknown || []) as string[]).join('|')
-                    row.push(multiSelectValue)
-                } else {
-                    // Export as string
-                    row.push(`"${this.encodeText(displayValue)}"`)
-                }
+                const property = propsRegistry.get(template.type)
+                row.push(property.exportValue(propertyValue, card, template, intl))
             })
             rows.push(row)
         })
diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts
index f37f3a4c1..74209a990 100644
--- a/webapp/src/mutator.ts
+++ b/webapp/src/mutator.ts
@@ -7,7 +7,7 @@ import cloneDeep from 'lodash/cloneDeep'
 
 import {BlockIcons} from './blockIcons'
 import {Block, BlockPatch, createPatchesFromBlocks} from './blocks/block'
-import {Board, BoardMember, BoardsAndBlocks, IPropertyOption, IPropertyTemplate, PropertyType, createBoard, createPatchesFromBoards, createPatchesFromBoardsAndBlocks, createCardPropertiesPatches} from './blocks/board'
+import {Board, BoardMember, BoardsAndBlocks, IPropertyOption, IPropertyTemplate, PropertyTypeEnum, createBoard, createPatchesFromBoards, createPatchesFromBoardsAndBlocks, createCardPropertiesPatches} from './blocks/board'
 import {BoardView, ISortOption, createBoardView, KanbanCalculationFields} from './blocks/boardView'
 import {Card, createCard} from './blocks/card'
 import {ContentBlock} from './blocks/contentBlock'
@@ -646,7 +646,7 @@ class Mutator {
         TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.EditCardProperty, {board: card.boardId, card: card.id})
     }
 
-    async changePropertyTypeAndName(board: Board, cards: Card[], propertyTemplate: IPropertyTemplate, newType: PropertyType, newName: string) {
+    async changePropertyTypeAndName(board: Board, cards: Card[], propertyTemplate: IPropertyTemplate, newType: PropertyTypeEnum, newName: string) {
         if (propertyTemplate.type === newType && propertyTemplate.name === newName) {
             return
         }
diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx
index 624778098..7d504f719 100644
--- a/webapp/src/octoUtils.tsx
+++ b/webapp/src/octoUtils.tsx
@@ -3,10 +3,7 @@
 
 import {IntlShape} from 'react-intl'
 
-import {DateUtils} from 'react-day-picker'
-
 import {Block, createBlock} from './blocks/block'
-import {IPropertyTemplate} from './blocks/board'
 import {BoardView, createBoardView} from './blocks/boardView'
 import {Card, createCard} from './blocks/card'
 import {createCommentBlock} from './blocks/commentBlock'
@@ -18,67 +15,6 @@ import {FilterCondition} from './blocks/filterClause'
 import {Utils} from './utils'
 
 class OctoUtils {
-    static propertyDisplayValue(block: Block, propertyValue: string | string[] | undefined, propertyTemplate: IPropertyTemplate, intl: IntlShape): string | string[] | undefined {
-        let displayValue: string | string[] | undefined
-        switch (propertyTemplate.type) {
-        case 'select': {
-            // The property value is the id of the template
-            if (propertyValue) {
-                const option = propertyTemplate.options.find((o) => o.id === propertyValue)
-                if (!option) {
-                    Utils.assertFailure(`Invalid select option ID ${propertyValue}, block.title: ${block.title}`)
-                }
-                displayValue = option?.value || '(Unknown)'
-            }
-            break
-        }
-        case 'multiSelect': {
-            if (propertyValue?.length) {
-                const options = propertyTemplate.options.filter((o) => propertyValue.includes(o.id))
-                if (!options.length) {
-                    Utils.assertFailure(`Invalid multiSelect option IDs ${propertyValue}, block.title: ${block.title}`)
-                }
-                displayValue = options.map((o) => o.value)
-            }
-            break
-        }
-        case 'createdTime': {
-            displayValue = Utils.displayDateTime(new Date(block.createAt), intl)
-            break
-        }
-        case 'updatedTime': {
-            displayValue = Utils.displayDateTime(new Date(block.updateAt), intl)
-            break
-        }
-        case 'date': {
-            if (propertyValue) {
-                const singleDate = new Date(parseInt(propertyValue as string, 10))
-                if (singleDate && DateUtils.isDate(singleDate)) {
-                    displayValue = Utils.displayDate(new Date(parseInt(propertyValue as string, 10)), intl)
-                } else {
-                    try {
-                        const dateValue = JSON.parse(propertyValue as string)
-                        if (dateValue.from) {
-                            displayValue = Utils.displayDate(new Date(dateValue.from), intl)
-                        }
-                        if (dateValue.to) {
-                            displayValue += ' -> '
-                            displayValue += Utils.displayDate(new Date(dateValue.to), intl)
-                        }
-                    } catch {
-                        // do nothing
-                    }
-                }
-            }
-            break
-        }
-        default:
-            displayValue = propertyValue
-        }
-
-        return displayValue
-    }
-
     static hydrateBlock(block: Block): Block {
         switch (block.type) {
         case 'view': { return createBoardView(block) }
@@ -166,6 +102,16 @@ class OctoUtils {
         case 'notIncludes': return intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})
         case 'isEmpty': return intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})
         case 'isNotEmpty': return intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})
+        case 'isSet': return intl.formatMessage({id: 'Filter.is-set', defaultMessage: 'is set'})
+        case 'isNotSet': return intl.formatMessage({id: 'Filter.is-not-set', defaultMessage: 'is not set'})
+        case 'isNotEmpty': return intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})
+        case 'is': return intl.formatMessage({id: 'Filter.is', defaultMessage: 'is'})
+        case 'contains': return intl.formatMessage({id: 'Filter.contains', defaultMessage: 'contains'})
+        case 'notContains': return intl.formatMessage({id: 'Filter.not-contains', defaultMessage: 'doesn\'t contain'})
+        case 'startsWith': return intl.formatMessage({id: 'Filter.starts-with', defaultMessage: 'starts with'})
+        case 'notStartsWith': return intl.formatMessage({id: 'Filter.not-starts-with', defaultMessage: 'doesn\'t start with'})
+        case 'endsWith': return intl.formatMessage({id: 'Filter.ends-with', defaultMessage: 'ends with'})
+        case 'notEndsWith': return intl.formatMessage({id: 'Filter.not-ends-with', defaultMessage: 'doesn\'t end with'})
         default: {
             Utils.assertFailure()
             return '(unknown)'
diff --git a/webapp/src/properties/baseTextEditor.tsx b/webapp/src/properties/baseTextEditor.tsx
new file mode 100644
index 000000000..0502f8ddb
--- /dev/null
+++ b/webapp/src/properties/baseTextEditor.tsx
@@ -0,0 +1,53 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useState, useRef, useEffect} from 'react'
+
+import {useIntl} from 'react-intl'
+
+import mutator from '../mutator'
+import Editable from '../widgets/editable'
+
+import {PropertyProps} from './types'
+
+const BaseTextEditor = (props: PropertyProps & {validator: () => boolean, spellCheck?: boolean}): JSX.Element => {
+    const [value, setValue] = useState(props.card.fields.properties[props.propertyTemplate.id || ''] || '')
+    const onCancel = useCallback(() => setValue(props.propertyValue || ''), [props.propertyValue])
+
+    const saveTextProperty = useCallback(() => {
+        if (value !== (props.card.fields.properties[props.propertyTemplate?.id || ''] || '')) {
+            mutator.changePropertyValue(props.board.id, props.card, props.propertyTemplate?.id || '', value)
+        }
+    }, [props.card, props.propertyTemplate, value])
+
+    const saveTextPropertyRef = useRef<() => void>(saveTextProperty)
+    saveTextPropertyRef.current = saveTextProperty
+
+    const intl = useIntl()
+    const emptyDisplayValue = props.showEmptyPlaceholder ? intl.formatMessage({id: 'PropertyValueElement.empty', defaultMessage: 'Empty'}) : ''
+
+    useEffect(() => {
+        return () => {
+            saveTextPropertyRef.current && saveTextPropertyRef.current()
+        }
+    }, [])
+
+    if (!props.readOnly) {
+        return (
+            <Editable
+                className={props.property.valueClassName(props.readOnly)}
+                placeholderText={emptyDisplayValue}
+                value={value.toString()}
+                autoExpand={true}
+                onChange={setValue}
+                onSave={saveTextProperty}
+                onCancel={onCancel}
+                validator={props.validator}
+                spellCheck={props.spellCheck}
+            />
+        )
+    }
+    return <div className={props.property.valueClassName(true)}>{props.propertyValue}</div>
+}
+
+export default BaseTextEditor
diff --git a/webapp/src/properties/checkbox/checkbox.tsx b/webapp/src/properties/checkbox/checkbox.tsx
new file mode 100644
index 000000000..815794f64
--- /dev/null
+++ b/webapp/src/properties/checkbox/checkbox.tsx
@@ -0,0 +1,24 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import mutator from '../../mutator'
+import Switch from '../../widgets/switch'
+
+import {PropertyProps} from '../types'
+
+const Checkbox = (props: PropertyProps): JSX.Element => {
+    const {card, board, propertyTemplate, propertyValue} = props
+    return (
+        <Switch
+            isOn={Boolean(propertyValue)}
+            onChanged={(newBool: boolean) => {
+                const newValue = newBool ? 'true' : ''
+                mutator.changePropertyValue(board.id, card, propertyTemplate?.id || '', newValue)
+            }}
+            readOnly={props.readOnly}
+        />
+    )
+}
+export default Checkbox
diff --git a/webapp/src/properties/checkbox/property.tsx b/webapp/src/properties/checkbox/property.tsx
new file mode 100644
index 000000000..8cf382b85
--- /dev/null
+++ b/webapp/src/properties/checkbox/property.tsx
@@ -0,0 +1,14 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Checkbox from './checkbox'
+
+export default class CheckboxProperty extends PropertyType {
+    Editor = Checkbox
+    name = 'Checkbox'
+    type = 'checkbox' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Checkbox', defaultMessage: 'Checkbox'})
+    canFilter = true
+    filterValueType = 'boolean' as FilterValueType
+}
diff --git a/webapp/src/properties/createdBy/__snapshots__/createdBy.test.tsx.snap b/webapp/src/properties/createdBy/__snapshots__/createdBy.test.tsx.snap
new file mode 100644
index 000000000..0be42ea84
--- /dev/null
+++ b/webapp/src/properties/createdBy/__snapshots__/createdBy.test.tsx.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`properties/createdBy should match snapshot 1`] = `
+<div>
+  <div
+    class="Person octo-propertyvalue octo-propertyvalue--readonly"
+  >
+    <div
+      class="Person-item"
+    >
+      username_1
+    </div>
+  </div>
+</div>
+`;
diff --git a/webapp/src/components/properties/createdBy/createdBy.test.tsx b/webapp/src/properties/createdBy/createdBy.test.tsx
similarity index 62%
rename from webapp/src/components/properties/createdBy/createdBy.test.tsx
rename to webapp/src/properties/createdBy/createdBy.test.tsx
index aff43c4fa..a7e5c06a3 100644
--- a/webapp/src/components/properties/createdBy/createdBy.test.tsx
+++ b/webapp/src/properties/createdBy/createdBy.test.tsx
@@ -7,12 +7,14 @@ import {Provider as ReduxProvider} from 'react-redux'
 import {render} from '@testing-library/react'
 import configureStore from 'redux-mock-store'
 
-import {IUser} from '../../../user'
-import {createCard} from '../../../blocks/card'
+import {IUser} from '../../user'
+import {createCard, Card} from '../../blocks/card'
+import {Board, IPropertyTemplate} from '../../blocks/board'
 
+import CreatedByProperty from './property'
 import CreatedBy from './createdBy'
 
-describe('components/properties/createdBy', () => {
+describe('properties/createdBy', () => {
     test('should match snapshot', () => {
         const card = createCard()
         card.createdBy = 'user-id-1'
@@ -33,7 +35,15 @@ describe('components/properties/createdBy', () => {
 
         const component = (
             <ReduxProvider store={store}>
-                <CreatedBy userID='user-id-1'/>
+                <CreatedBy
+                    property={new CreatedByProperty}
+                    board={{} as Board}
+                    card={{createdBy: 'user-id-1'} as Card}
+                    readOnly={false}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    propertyValue={''}
+                    showEmptyPlaceholder={false}
+                />
             </ReduxProvider>
         )
 
diff --git a/webapp/src/properties/createdBy/createdBy.tsx b/webapp/src/properties/createdBy/createdBy.tsx
new file mode 100644
index 000000000..03289ca83
--- /dev/null
+++ b/webapp/src/properties/createdBy/createdBy.tsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import Person from '../person/person'
+import {PropertyProps} from '../types'
+
+const CreatedBy = (props: PropertyProps): JSX.Element => {
+    return (
+        <Person
+            {...props}
+            propertyValue={props.card.createdBy}
+            readOnly={true} // created by is an immutable property, so will always be readonly
+        />
+    )
+}
+
+export default CreatedBy
diff --git a/webapp/src/properties/createdBy/property.tsx b/webapp/src/properties/createdBy/property.tsx
new file mode 100644
index 000000000..850132173
--- /dev/null
+++ b/webapp/src/properties/createdBy/property.tsx
@@ -0,0 +1,13 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import CreatedBy from './createdBy'
+
+export default class CreatedByProperty extends PropertyType {
+    Editor = CreatedBy
+    name = 'Created By'
+    type = 'createdBy' as PropertyTypeEnum
+    isReadOnly = true
+    displayName = (intl: IntlShape) => intl.formatMessage({id: 'PropertyType.CreatedBy', defaultMessage: 'Created by'})
+}
diff --git a/webapp/src/components/properties/createdAt/createdAt.scss b/webapp/src/properties/createdTime/createdTime.scss
similarity index 75%
rename from webapp/src/components/properties/createdAt/createdAt.scss
rename to webapp/src/properties/createdTime/createdTime.scss
index f33149894..8f81795fa 100644
--- a/webapp/src/components/properties/createdAt/createdAt.scss
+++ b/webapp/src/properties/createdTime/createdTime.scss
@@ -1,4 +1,4 @@
-.CreatedAt {
+.CreatedTime {
     display: flex;
     align-items: center;
 }
diff --git a/webapp/src/properties/createdTime/createdTime.tsx b/webapp/src/properties/createdTime/createdTime.tsx
new file mode 100644
index 000000000..7e9e40502
--- /dev/null
+++ b/webapp/src/properties/createdTime/createdTime.tsx
@@ -0,0 +1,21 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import {useIntl} from 'react-intl'
+
+import {Utils} from '../../utils'
+import {PropertyProps} from '../types'
+import './createdTime.scss'
+
+const CreatedTime = (props: PropertyProps): JSX.Element => {
+    const intl = useIntl()
+    return (
+        <div className={`CreatedTime ${props.property.valueClassName(true)}`}>
+            {Utils.displayDateTime(new Date(props.card.createAt), intl)}
+        </div>
+    )
+}
+
+export default CreatedTime
diff --git a/webapp/src/properties/createdTime/property.tsx b/webapp/src/properties/createdTime/property.tsx
new file mode 100644
index 000000000..ba0edd860
--- /dev/null
+++ b/webapp/src/properties/createdTime/property.tsx
@@ -0,0 +1,24 @@
+import {IntlShape} from 'react-intl'
+
+import {Options} from '../../components/calculations/options'
+import {IPropertyTemplate} from '../../blocks/board'
+import {Card} from '../../blocks/card'
+import {Utils} from '../../utils'
+
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import CreatedTime from './createdTime'
+
+export default class CreatedAtProperty extends PropertyType {
+    Editor = CreatedTime
+    name = 'Created At'
+    type = 'createdTime' as PropertyTypeEnum
+    isDate = true
+    isReadOnly = true
+    displayName = (intl: IntlShape) => intl.formatMessage({id: 'PropertyType.CreatedTime', defaultMessage: 'Created time'})
+    calculationOptions = [Options.none, Options.count, Options.countEmpty,
+        Options.countNotEmpty, Options.percentEmpty, Options.percentNotEmpty,
+        Options.countValue, Options.countUniqueValue, Options.earliest,
+        Options.latest, Options.dateRange]
+    displayValue = (_1: string | string[] | undefined, card: Card, _2: IPropertyTemplate, intl: IntlShape) => Utils.displayDateTime(new Date(card.createAt), intl)
+}
diff --git a/webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap b/webapp/src/properties/date/__snapshots__/date.test.tsx.snap
similarity index 73%
rename from webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap
rename to webapp/src/properties/date/__snapshots__/date.test.tsx.snap
index 892980837..861e203c5 100644
--- a/webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap
+++ b/webapp/src/properties/date/__snapshots__/date.test.tsx.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`components/properties/dateRange cancel set via text input 1`] = `
+exports[`properties/dateRange cancel set via text input 1`] = `
 <div>
   <div
     class="DateRange  octo-propertyvalue"
@@ -17,7 +17,7 @@ exports[`components/properties/dateRange cancel set via text input 1`] = `
 </div>
 `;
 
-exports[`components/properties/dateRange handle clear 1`] = `
+exports[`properties/dateRange handle clear 1`] = `
 <div>
   <div
     class="DateRange  octo-propertyvalue"
@@ -34,7 +34,7 @@ exports[`components/properties/dateRange handle clear 1`] = `
 </div>
 `;
 
-exports[`components/properties/dateRange returns default correctly 1`] = `
+exports[`properties/dateRange returns default correctly 1`] = `
 <div>
   <div
     class="DateRange empty octo-propertyvalue"
@@ -49,7 +49,7 @@ exports[`components/properties/dateRange returns default correctly 1`] = `
 </div>
 `;
 
-exports[`components/properties/dateRange returns local correctly - es local 1`] = `
+exports[`properties/dateRange returns local correctly - es local 1`] = `
 <div>
   <div
     class="DateRange  octo-propertyvalue"
@@ -66,7 +66,7 @@ exports[`components/properties/dateRange returns local correctly - es local 1`]
 </div>
 `;
 
-exports[`components/properties/dateRange set via text input 1`] = `
+exports[`properties/dateRange set via text input 1`] = `
 <div>
   <div
     class="DateRange  octo-propertyvalue"
@@ -83,7 +83,7 @@ exports[`components/properties/dateRange set via text input 1`] = `
 </div>
 `;
 
-exports[`components/properties/dateRange set via text input, es locale 1`] = `
+exports[`properties/dateRange set via text input, es locale 1`] = `
 <div>
   <div
     class="DateRange  octo-propertyvalue"
diff --git a/webapp/src/components/properties/dateRange/dateRange.scss b/webapp/src/properties/date/date.scss
similarity index 100%
rename from webapp/src/components/properties/dateRange/dateRange.scss
rename to webapp/src/properties/date/date.scss
diff --git a/webapp/src/components/properties/dateRange/dateRange.test.tsx b/webapp/src/properties/date/date.test.tsx
similarity index 58%
rename from webapp/src/components/properties/dateRange/dateRange.test.tsx
rename to webapp/src/properties/date/date.test.tsx
index d99305309..739a70a96 100644
--- a/webapp/src/components/properties/dateRange/dateRange.test.tsx
+++ b/webapp/src/properties/date/date.test.tsx
@@ -1,56 +1,57 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
 
-import React, {useState} from 'react'
+import React from 'react'
 import {render} from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
 import {IntlProvider} from 'react-intl'
+import {mocked} from 'jest-mock'
 
 import '@testing-library/jest-dom'
 
-import {wrapIntl} from '../../../testUtils'
-import {propertyValueClassName} from '../../propertyValueUtils'
+import {wrapIntl} from '../../testUtils'
+import {IPropertyTemplate, createBoard} from '../../blocks/board'
+import {createCard} from '../../blocks/card'
+import mutator from '../../mutator'
 
-import DateRange from '../dateRange/dateRange'
+import DateProperty from './property'
+import DateProp from './date'
+
+jest.mock('../../mutator')
+const mockedMutator = mocked(mutator, true)
 
 // create Dates for specific days for this year.
 const June15 = new Date(Date.UTC(new Date().getFullYear(), 5, 15, 12))
 const June15Local = new Date(new Date().getFullYear(), 5, 15, 12)
 const June20 = new Date(Date.UTC(new Date().getFullYear(), 5, 20, 12))
 
-type Props = {
-    initialValue?: string
-    showEmptyPlaceholder?: boolean
-    onChange?: (value: string) => void
-}
+describe('properties/dateRange', () => {
+    const card = createCard()
+    const board = createBoard()
+    const propertyTemplate: IPropertyTemplate = {
+        id: "test",
+        name: "test",
+        type: "date",
+        options: [],
+    }
 
-const DateRangeWrapper = (props: Props): JSX.Element => {
-    const [value, setValue] = useState(props.initialValue || '')
-    return (
-        <DateRange
-            className={propertyValueClassName()}
-            value={value}
-            showEmptyPlaceholder={props.showEmptyPlaceholder}
-            onChange={(newValue) => {
-                setValue(newValue)
-                props.onChange?.(newValue)
-            }}
-        />
-    )
-}
-
-describe('components/properties/dateRange', () => {
     beforeEach(() => {
         // Quick fix to disregard console error when unmounting a component
         console.error = jest.fn()
         document.execCommand = jest.fn()
+        jest.resetAllMocks()
     })
 
     test('returns default correctly', () => {
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue=''
-                onChange={jest.fn()}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue=''
+                showEmptyPlaceholder={false}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -61,9 +62,14 @@ describe('components/properties/dateRange', () => {
     test('returns local correctly - es local', () => {
         const component = (
             <IntlProvider locale='es'>
-                <DateRangeWrapper
-                    initialValue={June15Local.getTime().toString()}
-                    onChange={jest.fn()}
+                <DateProp
+                    property={new DateProperty()}
+                    propertyValue={June15Local.getTime().toString()}
+                    showEmptyPlaceholder={false}
+                    readOnly={false}
+                    board={{...board}}
+                    card={{...card}}
+                    propertyTemplate={propertyTemplate}
                 />
             </IntlProvider>
         )
@@ -75,12 +81,15 @@ describe('components/properties/dateRange', () => {
     })
 
     test('handles calendar click event', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue=''
+            <DateProp
+                property={new DateProperty()}
+                propertyValue=''
                 showEmptyPlaceholder={true}
-                onChange={callback}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -96,17 +105,19 @@ describe('components/properties/dateRange', () => {
         userEvent.click(day)
         userEvent.click(modal)
 
-        const rObject = {from: fifteenth}
-        expect(callback).toHaveBeenCalledWith(JSON.stringify(rObject))
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify({from: fifteenth}))
     })
 
     test('handles setting range', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue={''}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue={''}
                 showEmptyPlaceholder={true}
-                onChange={callback}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -132,16 +143,19 @@ describe('components/properties/dateRange', () => {
         userEvent.click(end)
         userEvent.click(modal)
 
-        const rObject = {from: fifteenth, to: twentieth}
-        expect(callback).toHaveBeenCalledWith(JSON.stringify(rObject))
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify({from: fifteenth, to: twentieth}))
     })
 
     test('handle clear', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue={June15Local.getTime().toString()}
-                onChange={callback}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue={June15Local.getTime().toString()}
+                showEmptyPlaceholder={false}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -157,15 +171,19 @@ describe('components/properties/dateRange', () => {
         userEvent.click(clear)
         userEvent.click(modal)
 
-        expect(callback).toHaveBeenCalledWith('')
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, '')
     })
 
     test('set via text input', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
-                onChange={callback}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
+                showEmptyPlaceholder={false}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -190,18 +208,21 @@ describe('components/properties/dateRange', () => {
         userEvent.click(modal)
 
         // {from: '2021-07-15', to: '2021-07-20'}
-        const retVal = '{"from":' + July15.getTime().toString() + ',"to":' + July20.getTime().toString() + '}'
-        expect(callback).toHaveBeenCalledWith(retVal)
+        const retVal = {from: July15.getTime(), to: July20.getTime()}
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify(retVal))
     })
 
     test('set via text input, es locale', () => {
-        const callback = jest.fn()
-
         const component = (
             <IntlProvider locale='es'>
-                <DateRangeWrapper
-                    initialValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
-                    onChange={callback}
+                <DateProp
+                    property={new DateProperty()}
+                    propertyValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
+                    showEmptyPlaceholder={false}
+                    readOnly={false}
+                    board={{...board}}
+                    card={{...card}}
+                    propertyTemplate={propertyTemplate}
                 />
             </IntlProvider>
         )
@@ -226,16 +247,20 @@ describe('components/properties/dateRange', () => {
         userEvent.click(modal)
 
         // {from: '2021-07-15', to: '2021-07-20'}
-        const retVal = '{"from":' + July15.getTime().toString() + ',"to":' + July20.getTime().toString() + '}'
-        expect(callback).toHaveBeenCalledWith(retVal)
+        const retVal = {from: July15.getTime(), to: July20.getTime()}
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify(retVal))
     })
 
     test('cancel set via text input', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
-                onChange={callback}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
+                showEmptyPlaceholder={false}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -255,17 +280,20 @@ describe('components/properties/dateRange', () => {
         userEvent.click(modal)
 
         // const retVal = {from: '2021-06-15', to: '2021-06-20'}
-        const retVal = '{"from":' + June15.getTime().toString() + ',"to":' + June20.getTime().toString() + '}'
-        expect(callback).toHaveBeenCalledWith(retVal)
+        const retVal = {from: June15.getTime(), to: June20.getTime()}
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify(retVal))
     })
 
     test('handles `Today` button click event', () => {
-        const callback = jest.fn()
         const component = wrapIntl(
-            <DateRangeWrapper
-                initialValue={''}
+            <DateProp
+                property={new DateProperty()}
+                propertyValue={''}
                 showEmptyPlaceholder={true}
-                onChange={callback}
+                readOnly={false}
+                board={{...board}}
+                card={{...card}}
+                propertyTemplate={propertyTemplate}
             />,
         )
 
@@ -285,7 +313,6 @@ describe('components/properties/dateRange', () => {
         userEvent.click(day)
         userEvent.click(modal)
 
-        const rObject = {from: today}
-        expect(callback).toHaveBeenCalledWith(JSON.stringify(rObject))
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, JSON.stringify({from: today}))
     })
 })
diff --git a/webapp/src/components/properties/dateRange/dateRange.tsx b/webapp/src/properties/date/date.tsx
similarity index 90%
rename from webapp/src/components/properties/dateRange/dateRange.tsx
rename to webapp/src/properties/date/date.tsx
index 965fabe2b..d5958415f 100644
--- a/webapp/src/components/properties/dateRange/dateRange.tsx
+++ b/webapp/src/properties/date/date.tsx
@@ -1,6 +1,6 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
-import React, {useMemo, useState} from 'react'
+import React, {useMemo, useState, useCallback} from 'react'
 import {useIntl} from 'react-intl'
 import {DateUtils} from 'react-day-picker'
 import MomentLocaleUtils from 'react-day-picker/moment'
@@ -8,23 +8,20 @@ import DayPicker from 'react-day-picker/DayPicker'
 
 import moment from 'moment'
 
-import Editable from '../../../widgets/editable'
-import SwitchOption from '../../../widgets/menu/switchOption'
-import Button from '../../../widgets/buttons/button'
+import mutator from '../../mutator'
 
-import Modal from '../../../components/modal'
-import ModalWrapper from '../../../components/modalWrapper'
+import Editable from '../../widgets/editable'
+import SwitchOption from '../../widgets/menu/switchOption'
+import Button from '../../widgets/buttons/button'
+
+import Modal from '../../components/modal'
+import ModalWrapper from '../../components/modalWrapper'
+import {Utils} from '../../utils'
 
 import 'react-day-picker/lib/style.css'
-import './dateRange.scss'
-import {Utils} from '../../../utils'
+import './date.scss'
 
-type Props = {
-    className: string
-    value: string
-    showEmptyPlaceholder?: boolean
-    onChange: (value: string) => void
-}
+import {PropertyProps} from '../types'
 
 export type DateProperty = {
     from?: number
@@ -56,10 +53,18 @@ function datePropertyToString(dateProperty: DateProperty): string {
 
 const loadedLocales: Record<string, moment.Locale> = {}
 
-function DateRange(props: Props): JSX.Element {
-    const {className, value, showEmptyPlaceholder, onChange} = props
+function DateRange(props: PropertyProps): JSX.Element {
+    const {propertyValue, propertyTemplate, showEmptyPlaceholder, readOnly, board, card} = props
+    const [value, setValue] = useState(propertyValue)
     const intl = useIntl()
 
+    const onChange = useCallback((newValue) => {
+        if (value !== newValue) {
+            setValue(newValue)
+            mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue)
+        }
+    }, [value, board.id, card, propertyTemplate.id])
+
     const getDisplayDate = (date: Date | null | undefined) => {
         let displayDate = ''
         if (date) {
@@ -153,6 +158,12 @@ function DateRange(props: Props): JSX.Element {
     if (!buttonText && showEmptyPlaceholder) {
         buttonText = intl.formatMessage({id: 'DateRange.empty', defaultMessage: 'Empty'})
     }
+
+    const className = props.property.valueClassName(readOnly)
+    if (readOnly) {
+        return <div className={className}>{displayValue}</div>
+    }
+
     return (
         <div className={`DateRange ${displayValue ? '' : 'empty'} ` + className}>
             <Button
diff --git a/webapp/src/properties/date/property.tsx b/webapp/src/properties/date/property.tsx
new file mode 100644
index 000000000..b9b417833
--- /dev/null
+++ b/webapp/src/properties/date/property.tsx
@@ -0,0 +1,76 @@
+import {IntlShape} from 'react-intl'
+import {DateUtils} from 'react-day-picker'
+
+import {Options} from '../../components/calculations/options'
+import {IPropertyTemplate} from '../../blocks/board'
+import {Card} from '../../blocks/card'
+import {Utils} from '../../utils'
+
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import DateComponent, {createDatePropertyFromString} from './date'
+
+const oneDay = 60 * 60 * 24 * 1000
+
+const timeZoneOffset = (date: number): number => {
+    return new Date(date).getTimezoneOffset() * 60 * 1000
+}
+
+export default class DateProperty extends PropertyType {
+    Editor = DateComponent
+    name = 'Date'
+    type = 'date' as PropertyTypeEnum
+    isDate = true
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Date', defaultMessage: 'Date'})
+    calculationOptions = [Options.none, Options.count, Options.countEmpty,
+        Options.countNotEmpty, Options.percentEmpty, Options.percentNotEmpty,
+        Options.countValue, Options.countUniqueValue]
+    displayValue = (propertyValue: string | string[] | undefined, _1: Card, _2: IPropertyTemplate, intl: IntlShape) => {
+        let displayValue = ''
+        if (propertyValue && typeof propertyValue === "string") {
+            const singleDate = new Date(parseInt(propertyValue, 10))
+            if (singleDate && DateUtils.isDate(singleDate)) {
+                displayValue = Utils.displayDate(new Date(parseInt(propertyValue, 10)), intl)
+            } else {
+                try {
+                    const dateValue = JSON.parse(propertyValue as string)
+                    if (dateValue.from) {
+                        displayValue = Utils.displayDate(new Date(dateValue.from), intl)
+                    }
+                    if (dateValue.to) {
+                        displayValue += ' -> '
+                        displayValue += Utils.displayDate(new Date(dateValue.to), intl)
+                    }
+                } catch {
+                    // do nothing
+                }
+            }
+        }
+        return displayValue
+    }
+
+    getDateFrom = (value: string | string[] | undefined, card: Card) => {
+        const dateProperty = createDatePropertyFromString(value as string)
+        if (!dateProperty.from) {
+            return new Date(card.createAt || 0)
+        }
+        // date properties are stored as 12 pm UTC, convert to 12 am (00) UTC for calendar
+        const dateFrom = dateProperty.from ? new Date(dateProperty.from + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.from))) : new Date()
+        dateFrom.setHours(0, 0, 0, 0)
+        return dateFrom
+    }
+
+    getDateTo = (value: string | string[] | undefined, card: Card) => {
+        const dateProperty = createDatePropertyFromString(value as string)
+        if (!dateProperty.from) {
+            return new Date(card.createAt || 0)
+        }
+        const dateFrom = dateProperty.from ? new Date(dateProperty.from + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.from))) : new Date()
+        dateFrom.setHours(0, 0, 0, 0)
+
+        const dateToNumber = dateProperty.to ? dateProperty.to + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.to)) : dateFrom.getTime()
+        const dateTo = new Date(dateToNumber + oneDay) // Add one day.
+        dateTo.setHours(0, 0, 0, 0)
+        return dateTo
+    }
+}
diff --git a/webapp/src/properties/email/email.tsx b/webapp/src/properties/email/email.tsx
new file mode 100644
index 000000000..be2bfddd9
--- /dev/null
+++ b/webapp/src/properties/email/email.tsx
@@ -0,0 +1,20 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import {PropertyProps} from '../types'
+import BaseTextEditor from '../baseTextEditor'
+
+const Email = (props: PropertyProps): JSX.Element => {
+    return (
+        <BaseTextEditor
+            {...props}
+            validator={() => {
+                const emailRegexp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+                return emailRegexp.test(props.propertyValue as string)
+            }}
+        />
+    )
+}
+export default Email
diff --git a/webapp/src/properties/email/property.tsx b/webapp/src/properties/email/property.tsx
new file mode 100644
index 000000000..b5022ce13
--- /dev/null
+++ b/webapp/src/properties/email/property.tsx
@@ -0,0 +1,14 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Email from './email'
+
+export default class EmailProperty extends PropertyType {
+    Editor = Email
+    name = 'Email'
+    type = 'email' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Email', defaultMessage: 'Email'})
+    canFilter = true
+    filterValueType = 'text' as FilterValueType
+}
diff --git a/webapp/src/properties/index.tsx b/webapp/src/properties/index.tsx
new file mode 100644
index 000000000..9163c22b0
--- /dev/null
+++ b/webapp/src/properties/index.tsx
@@ -0,0 +1,61 @@
+import {PropertyTypeEnum} from '../blocks/board'
+
+import CreatedTimeProperty from './createdTime/property'
+import CreatedByProperty from './createdBy/property'
+import UpdatedTimeProperty from './updatedTime/property'
+import UpdatedByProperty from './updatedBy/property'
+import TextProperty from './text/property'
+import EmailProperty from './email/property'
+import PhoneProperty from './phone/property'
+import NumberProperty from './number/property'
+import UrlProperty from './url/property'
+import SelectProperty from './select/property'
+import MultiSelectProperty from './multiselect/property'
+import DateProperty from './date/property'
+import PersonProperty from './person/property'
+import CheckboxProperty from './checkbox/property'
+import UnknownProperty from './unknown/property'
+
+import {PropertyType} from './types'
+
+class PropertiesRegistry {
+    properties: {[key:string]: PropertyType} = {}
+    propertiesList: PropertyType[] = []
+    unknownProperty: PropertyType = new UnknownProperty()
+
+    register(prop: PropertyType) {
+        this.properties[prop.type] = prop
+        this.propertiesList.push(prop)
+    }
+
+    unregister(prop: PropertyType) {
+        delete this.properties[prop.type]
+        this.propertiesList = this.propertiesList.filter((p) => p.type == prop.type)
+    }
+
+    list() {
+        return this.propertiesList
+    }
+
+    get(type: PropertyTypeEnum) {
+        return this.properties[type] || this.unknownProperty
+    }
+}
+
+const registry = new PropertiesRegistry()
+registry.register(new TextProperty())
+registry.register(new NumberProperty())
+registry.register(new EmailProperty())
+registry.register(new PhoneProperty())
+registry.register(new UrlProperty())
+registry.register(new SelectProperty())
+registry.register(new MultiSelectProperty())
+registry.register(new DateProperty())
+registry.register(new PersonProperty())
+registry.register(new CheckboxProperty())
+registry.register(new CreatedTimeProperty())
+registry.register(new CreatedByProperty())
+registry.register(new UpdatedTimeProperty())
+registry.register(new UpdatedByProperty())
+
+export default registry
diff --git a/webapp/src/components/properties/multiSelect/__snapshots__/multiSelect.test.tsx.snap b/webapp/src/properties/multiselect/__snapshots__/multiselect.test.tsx.snap
similarity index 75%
rename from webapp/src/components/properties/multiSelect/__snapshots__/multiSelect.test.tsx.snap
rename to webapp/src/properties/multiselect/__snapshots__/multiselect.test.tsx.snap
index 85e90bae5..ef99e8de2 100644
--- a/webapp/src/components/properties/multiSelect/__snapshots__/multiSelect.test.tsx.snap
+++ b/webapp/src/properties/multiselect/__snapshots__/multiselect.test.tsx.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`components/properties/multiSelect shows only the selected options when menu is not opened 1`] = `
+exports[`properties/multiSelect shows only the selected options when menu is not opened 1`] = `
 <div>
   <div
     class="octo-propertyvalue octo-propertyvalue--readonly"
diff --git a/webapp/src/components/properties/multiSelect/multiSelect.test.tsx b/webapp/src/properties/multiselect/multiselect.test.tsx
similarity index 64%
rename from webapp/src/components/properties/multiSelect/multiSelect.test.tsx
rename to webapp/src/properties/multiselect/multiselect.test.tsx
index f7f5deba7..72cf39557 100644
--- a/webapp/src/components/properties/multiSelect/multiSelect.test.tsx
+++ b/webapp/src/properties/multiselect/multiselect.test.tsx
@@ -5,10 +5,17 @@ import {render, screen} from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
 import '@testing-library/jest-dom'
 import {IntlProvider} from 'react-intl'
+import {mocked} from 'jest-mock'
 
-import {IPropertyOption, IPropertyTemplate} from '../../../blocks/board'
+import {IPropertyOption, IPropertyTemplate, createBoard} from '../../blocks/board'
+import {createCard} from '../../blocks/card'
+import mutator from '../../mutator'
 
-import MultiSelect from './multiSelect'
+import MultiSelectProperty from './property'
+import MultiSelect from './multiselect'
+
+jest.mock('../../mutator')
+const mockedMutator = mocked(mutator, true)
 
 function buildMultiSelectPropertyTemplate(options: IPropertyOption[] = []) : IPropertyTemplate {
     return {
@@ -44,24 +51,29 @@ const Wrapper = ({children}: WrapperProps) => {
     return <IntlProvider locale='en'>{children}</IntlProvider>
 }
 
-describe('components/properties/multiSelect', () => {
+describe('properties/multiSelect', () => {
     const nonEditableMultiSelectTestId = 'multiselect-non-editable'
 
+    const board = createBoard()
+    const card = createCard()
+
+    beforeEach(() => {
+        jest.resetAllMocks()
+    })
+
     it('shows only the selected options when menu is not opened', () => {
         const propertyTemplate = buildMultiSelectPropertyTemplate()
         const propertyValue = ['multi-option-1', 'multi-option-2']
 
         const {container} = render(
             <MultiSelect
-                isEditable={false}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={true}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={jest.fn()}
-                onChangeColor={jest.fn()}
-                onDeleteOption={jest.fn()}
-                onCreate={jest.fn()}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -78,15 +90,13 @@ describe('components/properties/multiSelect', () => {
 
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={[]}
-                onChange={jest.fn()}
-                onChangeColor={jest.fn()}
-                onDeleteOption={jest.fn()}
-                onCreate={jest.fn()}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -99,19 +109,16 @@ describe('components/properties/multiSelect', () => {
     it('can select a option', async () => {
         const propertyTemplate = buildMultiSelectPropertyTemplate()
         const propertyValue = ['multi-option-1']
-        const onChange = jest.fn()
 
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={onChange}
-                onChangeColor={jest.fn()}
-                onDeleteOption={jest.fn()}
-                onCreate={jest.fn()}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -120,25 +127,22 @@ describe('components/properties/multiSelect', () => {
 
         userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), 'b{enter}')
 
-        expect(onChange).toHaveBeenCalledWith(['multi-option-1', 'multi-option-2'])
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, ['multi-option-1', 'multi-option-2'])
     })
 
     it('can unselect a option', async () => {
         const propertyTemplate = buildMultiSelectPropertyTemplate()
-        const propertyValue = ['multi-option-1']
-        const onDeleteValue = jest.fn()
+        const propertyValue = ['multi-option-1', 'multi-option-2']
 
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={jest.fn()}
-                onChangeColor={jest.fn()}
-                onDeleteOption={jest.fn()}
-                onCreate={jest.fn()}
-                onDeleteValue={onDeleteValue}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -147,57 +151,47 @@ describe('components/properties/multiSelect', () => {
 
         userEvent.click(screen.getAllByRole('button', {name: /clear/i})[0])
 
-        const valueToRemove = propertyTemplate.options.find((option: IPropertyOption) => option.id === propertyValue[0])
-        const selectedValues = propertyTemplate.options.filter((option: IPropertyOption) => propertyValue.includes(option.id))
-
-        expect(onDeleteValue).toHaveBeenCalledWith(valueToRemove, selectedValues)
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, ['multi-option-2'])
     })
 
     it('can create a new option', async () => {
         const propertyTemplate = buildMultiSelectPropertyTemplate()
         const propertyValue = ['multi-option-1', 'multi-option-2']
-        const onCreate = jest.fn()
 
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={jest.fn()}
-                onChangeColor={jest.fn()}
-                onDeleteOption={jest.fn()}
-                onCreate={onCreate}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
 
-        userEvent.click(screen.getByTestId(nonEditableMultiSelectTestId))
+        mockedMutator.insertPropertyOption.mockResolvedValue()
 
+        userEvent.click(screen.getByTestId(nonEditableMultiSelectTestId))
         userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), 'new-value{enter}')
 
-        const selectedValues = propertyTemplate.options.filter((option: IPropertyOption) => propertyValue.includes(option.id))
-
-        expect(onCreate).toHaveBeenCalledWith('new-value', selectedValues)
+        expect(mockedMutator.insertPropertyOption).toHaveBeenCalledWith(board.id, board.cardProperties, propertyTemplate, expect.objectContaining({value: 'new-value'}), 'add property option')
     })
 
     it('can delete a option', () => {
         const propertyTemplate = buildMultiSelectPropertyTemplate()
         const propertyValue = ['multi-option-1', 'multi-option-2']
 
-        const onDeleteOption = jest.fn()
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={jest.fn()}
-                onChangeColor={jest.fn()}
-                onDeleteOption={onDeleteOption}
-                onCreate={jest.fn()}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -210,7 +204,7 @@ describe('components/properties/multiSelect', () => {
 
         const optionToDelete = propertyTemplate.options.find((option: IPropertyOption) => option.id === propertyValue[0])
 
-        expect(onDeleteOption).toHaveBeenCalledWith(optionToDelete)
+        expect(mockedMutator.deletePropertyOption).toHaveBeenCalledWith(board.id, board.cardProperties, propertyTemplate, optionToDelete)
     })
 
     it('can change color for any option', () => {
@@ -219,18 +213,15 @@ describe('components/properties/multiSelect', () => {
         const newColorKey = 'propColorYellow'
         const newColorValue = 'yellow'
 
-        const onChangeColor = jest.fn()
         render(
             <MultiSelect
-                isEditable={true}
-                emptyValue={''}
+                property={new MultiSelectProperty()}
+                readOnly={false}
+                showEmptyPlaceholder={false}
                 propertyTemplate={propertyTemplate}
                 propertyValue={propertyValue}
-                onChange={jest.fn()}
-                onChangeColor={onChangeColor}
-                onDeleteOption={jest.fn()}
-                onCreate={jest.fn()}
-                onDeleteValue={jest.fn()}
+                board={{...board}}
+                card={{...card}}
             />,
             {wrapper: Wrapper},
         )
@@ -243,6 +234,6 @@ describe('components/properties/multiSelect', () => {
 
         const selectedOption = propertyTemplate.options.find((option: IPropertyOption) => option.id === propertyValue[0])
 
-        expect(onChangeColor).toHaveBeenCalledWith(selectedOption, newColorKey)
+        expect(mockedMutator.changePropertyOptionColor).toHaveBeenCalledWith(board.id, board.cardProperties, propertyTemplate, selectedOption, newColorKey)
     })
 })
diff --git a/webapp/src/properties/multiselect/multiselect.tsx b/webapp/src/properties/multiselect/multiselect.tsx
new file mode 100644
index 000000000..381ccbf89
--- /dev/null
+++ b/webapp/src/properties/multiselect/multiselect.tsx
@@ -0,0 +1,91 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useState, useCallback} from 'react'
+import {useIntl} from 'react-intl'
+
+import {IPropertyOption} from '../../blocks/board'
+import {Utils, IDType} from '../../utils'
+
+import mutator from '../../mutator'
+
+import Label from '../../widgets/label'
+import ValueSelector from '../../widgets/valueSelector'
+
+import {PropertyProps} from '../types'
+
+const MultiSelectProperty = (props: PropertyProps): JSX.Element => {
+    const {propertyTemplate, propertyValue, board, card} = props
+    const isEditable = !props.readOnly && Boolean(board)
+    const [open, setOpen] = useState(false)
+    const intl = useIntl()
+
+    const emptyDisplayValue = props.showEmptyPlaceholder ? intl.formatMessage({id: 'PropertyValueElement.empty', defaultMessage: 'Empty'}) : ''
+
+    const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate])
+    const onChangeColor = useCallback((option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(board.id, board.cardProperties, propertyTemplate, option, colorId), [board, propertyTemplate])
+    const onDeleteOption = useCallback((option: IPropertyOption) => mutator.deletePropertyOption(board.id, board.cardProperties, propertyTemplate, option), [board, propertyTemplate])
+
+    const onDeleteValue = useCallback((valueToDelete: IPropertyOption, currentValues: IPropertyOption[]) => {
+        const newValues = currentValues.
+            filter((currentValue) => currentValue.id !== valueToDelete.id).
+            map((currentValue) => currentValue.id)
+        mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValues)
+    }, [board.id, card, propertyTemplate.id])
+
+    const onCreateValue = useCallback((newValue: string, currentValues: IPropertyOption[]) => {
+        const option: IPropertyOption = {
+            id: Utils.createGuid(IDType.BlockID),
+            value: newValue,
+            color: 'propColorDefault',
+        }
+        currentValues.push(option)
+        mutator.insertPropertyOption(board.id, board.cardProperties, propertyTemplate, option, 'add property option').then(() => {
+            mutator.changePropertyValue(board.id, card, propertyTemplate.id, currentValues.map((v: IPropertyOption) => v.id))
+        })
+    }, [board, board.id, card, propertyTemplate])
+
+    const values = Array.isArray(propertyValue) && propertyValue.length > 0 ? propertyValue.map((v) => propertyTemplate.options.find((o) => o!.id === v)).filter((v): v is IPropertyOption => Boolean(v)) : []
+
+    if (!isEditable || !open) {
+        return (
+            <div
+                className={props.property.valueClassName(!isEditable)}
+                tabIndex={0}
+                data-testid='multiselect-non-editable'
+                onClick={() => setOpen(true)}
+            >
+                {values.map((v) => (
+                    <Label
+                        key={v.id}
+                        color={v.color}
+                    >
+                        {v.value}
+                    </Label>
+                ))}
+                {values.length === 0 && (
+                    <Label
+                        color='empty'
+                    >{emptyDisplayValue}</Label>
+                )}
+            </div>
+        )
+    }
+
+    return (
+        <ValueSelector
+            isMulti={true}
+            emptyValue={emptyDisplayValue}
+            options={propertyTemplate.options}
+            value={values}
+            onChange={onChange}
+            onChangeColor={onChangeColor}
+            onDeleteOption={onDeleteOption}
+            onDeleteValue={(valueToRemove) => onDeleteValue(valueToRemove, values)}
+            onCreate={(newValue) => onCreateValue(newValue, values)}
+            onBlur={() => setOpen(false)}
+        />
+    )
+}
+
+export default MultiSelectProperty
diff --git a/webapp/src/properties/multiselect/property.tsx b/webapp/src/properties/multiselect/property.tsx
new file mode 100644
index 000000000..5558ce4d7
--- /dev/null
+++ b/webapp/src/properties/multiselect/property.tsx
@@ -0,0 +1,46 @@
+import {IntlShape} from 'react-intl'
+
+import {IPropertyTemplate} from '../../blocks/board'
+import {Card} from '../../blocks/card'
+import {Utils} from '../../utils'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import MultiSelect from './multiselect'
+
+export default class MultiSelectProperty extends PropertyType {
+    Editor = MultiSelect
+    name = 'MultiSelect'
+    type = 'multiSelect' as PropertyTypeEnum
+    canFilter = true
+    filterValueType = 'options' as FilterValueType
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.MultiSelect', defaultMessage: 'Multi select'})
+    displayValue = (propertyValue: string | string[] | undefined, card: Card, propertyTemplate: IPropertyTemplate) => {
+        if (propertyValue?.length) {
+            const options = propertyTemplate.options.filter((o) => propertyValue.includes(o.id))
+            if (!options.length) {
+                Utils.assertFailure(`Invalid multiSelect option IDs ${propertyValue}, block.title: ${card.title}`)
+            }
+            return options.map((o) => o.value)
+        }
+        return ''
+    }
+
+    exportValue = (value: string | string[] | undefined, card: Card, template: IPropertyTemplate): string => {
+        const displayValue = this.displayValue(value, card, template)
+        return ((displayValue as unknown || []) as string[]).join('|')
+    }
+
+    valueLength = (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, _: IntlShape, fontDescriptor: string, perItemPadding?: number): number => {
+        const displayValue = this.displayValue(value, card, template)
+        if (!displayValue) {
+            return 0
+        }
+        const displayValues = displayValue as string[]
+        let result = 0
+        displayValues.forEach((v) => {
+            result += Utils.getTextWidth(v.toUpperCase(), fontDescriptor) + (perItemPadding || 0)
+        })
+        return result
+    }
+}
diff --git a/webapp/src/properties/number/number.tsx b/webapp/src/properties/number/number.tsx
new file mode 100644
index 000000000..b22b43105
--- /dev/null
+++ b/webapp/src/properties/number/number.tsx
@@ -0,0 +1,17 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import {PropertyProps} from '../types'
+import BaseTextEditor from '../baseTextEditor'
+
+const Number = (props: PropertyProps): JSX.Element => {
+    return (
+        <BaseTextEditor
+            {...props}
+            validator={() => !isNaN(parseInt(props.propertyValue as string, 10))}
+        />
+    )
+}
+export default Number
diff --git a/webapp/src/properties/number/property.tsx b/webapp/src/properties/number/property.tsx
new file mode 100644
index 000000000..9d964ad37
--- /dev/null
+++ b/webapp/src/properties/number/property.tsx
@@ -0,0 +1,20 @@
+import {IntlShape} from 'react-intl'
+
+import {Options} from '../../components/calculations/options'
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import NumberProp from './number'
+
+export default class NumberProperty extends PropertyType {
+    Editor = NumberProp
+    name = 'Number'
+    type = 'number' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Number', defaultMessage: 'Number'})
+    calculationOptions = [Options.none, Options.count, Options.countEmpty,
+        Options.countNotEmpty, Options.percentEmpty, Options.percentNotEmpty,
+        Options.countValue, Options.countUniqueValue, Options.sum,
+        Options.average, Options.median, Options.min, Options.max,
+        Options.range]
+
+    exportValue = (value: string | string[] | undefined): string => (value ? Number(value).toString() : '')
+}
diff --git a/webapp/src/components/properties/user/__snapshots__/user.test.tsx.snap b/webapp/src/properties/person/__snapshots__/person.test.tsx.snap
similarity index 93%
rename from webapp/src/components/properties/user/__snapshots__/user.test.tsx.snap
rename to webapp/src/properties/person/__snapshots__/person.test.tsx.snap
index 9c64d7240..967efdd65 100644
--- a/webapp/src/components/properties/user/__snapshots__/user.test.tsx.snap
+++ b/webapp/src/properties/person/__snapshots__/person.test.tsx.snap
@@ -1,107 +1,9 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`components/properties/user not readonly 1`] = `
+exports[`properties/user not readOnly not existing user 1`] = `
 <div>
   <div
-    class="UserProperty octo-propertyvalue css-b62m3t-container"
-  >
-    <span
-      class="css-1f43avz-a11yText-A11yText"
-      id="react-select-3-live-region"
-    />
-    <span
-      aria-atomic="false"
-      aria-live="polite"
-      aria-relevant="additions text"
-      class="css-1f43avz-a11yText-A11yText"
-    />
-    <div
-      class="react-select__control css-18140j1-Control"
-    >
-      <div
-        class="react-select__value-container react-select__value-container--has-value css-433wy7-ValueContainer"
-      >
-        <div
-          class="react-select__single-value css-1lixa2z-singleValue"
-        >
-          <div
-            class="UserProperty-item"
-          >
-            username-1
-          </div>
-        </div>
-        <div
-          class="react-select__input-container css-ox1y69-Input"
-          data-value=""
-        >
-          <input
-            aria-autocomplete="list"
-            aria-expanded="false"
-            aria-haspopup="true"
-            autocapitalize="none"
-            autocomplete="off"
-            autocorrect="off"
-            class="react-select__input"
-            id="react-select-3-input"
-            role="combobox"
-            spellcheck="false"
-            style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
-            tabindex="0"
-            type="text"
-            value=""
-          />
-        </div>
-      </div>
-      <div
-        class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
-      >
-        <div
-          aria-hidden="true"
-          class="react-select__indicator react-select__clear-indicator css-tpaeio-indicatorContainer"
-        >
-          <svg
-            aria-hidden="true"
-            class="css-tj5bde-Svg"
-            focusable="false"
-            height="20"
-            viewBox="0 0 20 20"
-            width="20"
-          >
-            <path
-              d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
-            />
-          </svg>
-        </div>
-        <span
-          class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
-        />
-        <div
-          aria-hidden="true"
-          class="react-select__indicator react-select__dropdown-indicator css-19sxey8-indicatorContainer"
-        >
-          <svg
-            aria-hidden="true"
-            class="css-tj5bde-Svg"
-            focusable="false"
-            height="20"
-            viewBox="0 0 20 20"
-            width="20"
-          >
-            <path
-              d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
-            />
-          </svg>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
-`;
-
-exports[`components/properties/user not readonly not existing user 1`] = `
-<div>
-  <div
-    class="UserProperty octo-propertyvalue css-b62m3t-container"
+    class="Person octo-propertyvalue css-b62m3t-container"
   >
     <span
       class="css-1f43avz-a11yText-A11yText"
@@ -177,13 +79,111 @@ exports[`components/properties/user not readonly not existing user 1`] = `
 </div>
 `;
 
-exports[`components/properties/user readonly view 1`] = `
+exports[`properties/user not readonly 1`] = `
 <div>
   <div
-    class="UserProperty octo-propertyvalue octo-propertyvalue--readonly"
+    class="Person octo-propertyvalue css-b62m3t-container"
+  >
+    <span
+      class="css-1f43avz-a11yText-A11yText"
+      id="react-select-3-live-region"
+    />
+    <span
+      aria-atomic="false"
+      aria-live="polite"
+      aria-relevant="additions text"
+      class="css-1f43avz-a11yText-A11yText"
+    />
+    <div
+      class="react-select__control css-18140j1-Control"
+    >
+      <div
+        class="react-select__value-container react-select__value-container--has-value css-433wy7-ValueContainer"
+      >
+        <div
+          class="react-select__single-value css-1lixa2z-singleValue"
+        >
+          <div
+            class="Person-item"
+          >
+            username-1
+          </div>
+        </div>
+        <div
+          class="react-select__input-container css-ox1y69-Input"
+          data-value=""
+        >
+          <input
+            aria-autocomplete="list"
+            aria-expanded="false"
+            aria-haspopup="true"
+            autocapitalize="none"
+            autocomplete="off"
+            autocorrect="off"
+            class="react-select__input"
+            id="react-select-3-input"
+            role="combobox"
+            spellcheck="false"
+            style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
+            tabindex="0"
+            type="text"
+            value=""
+          />
+        </div>
+      </div>
+      <div
+        class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
+      >
+        <div
+          aria-hidden="true"
+          class="react-select__indicator react-select__clear-indicator css-tpaeio-indicatorContainer"
+        >
+          <svg
+            aria-hidden="true"
+            class="css-tj5bde-Svg"
+            focusable="false"
+            height="20"
+            viewBox="0 0 20 20"
+            width="20"
+          >
+            <path
+              d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
+            />
+          </svg>
+        </div>
+        <span
+          class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
+        />
+        <div
+          aria-hidden="true"
+          class="react-select__indicator react-select__dropdown-indicator css-19sxey8-indicatorContainer"
+        >
+          <svg
+            aria-hidden="true"
+            class="css-tj5bde-Svg"
+            focusable="false"
+            height="20"
+            viewBox="0 0 20 20"
+            width="20"
+          >
+            <path
+              d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
+            />
+          </svg>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`properties/user readonly view 1`] = `
+<div>
+  <div
+    class="Person octo-propertyvalue octo-propertyvalue--readonly"
   >
     <div
-      class="UserProperty-item"
+      class="Person-item"
     >
       username-1
     </div>
@@ -191,10 +191,10 @@ exports[`components/properties/user readonly view 1`] = `
 </div>
 `;
 
-exports[`components/properties/user user dropdown open 1`] = `
+exports[`properties/user user dropdown open 1`] = `
 <div>
   <div
-    class="UserProperty octo-propertyvalue css-b62m3t-container"
+    class="Person octo-propertyvalue css-b62m3t-container"
   >
     <span
       class="css-1f43avz-a11yText-A11yText"
@@ -225,7 +225,7 @@ exports[`components/properties/user user dropdown open 1`] = `
           class="react-select__single-value css-1lixa2z-singleValue"
         >
           <div
-            class="UserProperty-item"
+            class="Person-item"
           >
             username-1
           </div>
@@ -310,7 +310,7 @@ exports[`components/properties/user user dropdown open 1`] = `
           tabindex="-1"
         >
           <div
-            class="UserProperty-item"
+            class="Person-item"
           >
             username-1
           </div>
diff --git a/webapp/src/components/properties/user/user.scss b/webapp/src/properties/person/person.scss
similarity index 95%
rename from webapp/src/components/properties/user/user.scss
rename to webapp/src/properties/person/person.scss
index dce01abc1..2283ef097 100644
--- a/webapp/src/components/properties/user/user.scss
+++ b/webapp/src/properties/person/person.scss
@@ -1,4 +1,4 @@
-.UserProperty {
+.Person {
     padding: 4px 8px;
     margin-right: 20px;
     min-width: 180px;
@@ -12,7 +12,7 @@
         min-width: unset;
     }
 
-    .UserProperty-item {
+    .Person-item {
         display: flex;
         align-items: center;
 
@@ -57,7 +57,7 @@
             color: rgba(var(--center-channel-color-rgb), 1);
         }
 
-        .UserProperty-item {
+        .Person-item {
             img {
                 margin-right: 12px;
             }
diff --git a/webapp/src/components/properties/user/user.test.tsx b/webapp/src/properties/person/person.test.tsx
similarity index 66%
rename from webapp/src/components/properties/user/user.test.tsx
rename to webapp/src/properties/person/person.test.tsx
index 6d9236f15..b0d50af35 100644
--- a/webapp/src/components/properties/user/user.test.tsx
+++ b/webapp/src/properties/person/person.test.tsx
@@ -12,11 +12,14 @@ import {act} from 'react-dom/test-utils'
 
 import userEvent from '@testing-library/user-event'
 
-import {wrapIntl} from '../../../testUtils'
+import {wrapIntl} from '../../testUtils'
+import {IPropertyTemplate, Board} from '../../blocks/board'
+import {Card} from '../../blocks/card'
 
-import UserProperty from './user'
+import PersonProperty from './property'
+import Person from './person'
 
-describe('components/properties/user', () => {
+describe('properties/user', () => {
     const mockStore = configureStore([])
     const state = {
         users: {
@@ -39,15 +42,18 @@ describe('components/properties/user', () => {
         },
     }
 
-    test('not readonly not existing user', async () => {
+    test('not readOnly not existing user', async () => {
         const store = mockStore(state)
         const component = wrapIntl(
             <ReduxProvider store={store}>
-                <UserProperty
-                    value={'user-id-2'}
-                    readonly={false}
-                    onChange={() => {
-                    }}
+                <Person
+                    property={new PersonProperty()}
+                    propertyValue={'user-id-2'}
+                    readOnly={false}
+                    showEmptyPlaceholder={false}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    board={{} as Board}
+                    card={{} as Card}
                 />
             </ReduxProvider>,
         )
@@ -66,11 +72,14 @@ describe('components/properties/user', () => {
         const store = mockStore(state)
         const component = wrapIntl(
             <ReduxProvider store={store}>
-                <UserProperty
-                    value={'user-id-1'}
-                    readonly={false}
-                    onChange={() => {
-                    }}
+                <Person
+                    property={new PersonProperty()}
+                    propertyValue={'user-id-1'}
+                    readOnly={false}
+                    showEmptyPlaceholder={false}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    board={{} as Board}
+                    card={{} as Card}
                 />
             </ReduxProvider>,
         )
@@ -89,11 +98,14 @@ describe('components/properties/user', () => {
         const store = mockStore(state)
         const component = wrapIntl(
             <ReduxProvider store={store}>
-                <UserProperty
-                    value={'user-id-1'}
-                    readonly={true}
-                    onChange={() => {
-                    }}
+                <Person
+                    property={new PersonProperty()}
+                    propertyValue={'user-id-1'}
+                    readOnly={true}
+                    showEmptyPlaceholder={false}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    board={{} as Board}
+                    card={{} as Card}
                 />
             </ReduxProvider>,
         )
@@ -112,10 +124,14 @@ describe('components/properties/user', () => {
         const store = mockStore(state)
         const component = wrapIntl(
             <ReduxProvider store={store}>
-                <UserProperty
-                    value={'user-id-1'}
-                    readonly={false}
-                    onChange={() => {}}
+                <Person
+                    property={new PersonProperty()}
+                    propertyValue={'user-id-1'}
+                    readOnly={false}
+                    showEmptyPlaceholder={false}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    board={{} as Board}
+                    card={{} as Card}
                 />
             </ReduxProvider>,
         )
@@ -131,7 +147,7 @@ describe('components/properties/user', () => {
         if (container) {
             // this is the actual element where the click event triggers
             // opening of the dropdown
-            const userProperty = container.querySelector('.UserProperty > div > div:nth-child(1) > div:nth-child(2) > input')
+            const userProperty = container.querySelector('.Person > div > div:nth-child(1) > div:nth-child(2) > input')
             expect(userProperty).not.toBeNull()
 
             act(() => {
diff --git a/webapp/src/components/properties/user/user.tsx b/webapp/src/properties/person/person.tsx
similarity index 66%
rename from webapp/src/components/properties/user/user.tsx
rename to webapp/src/properties/person/person.tsx
index 6f3db0c34..db25c77ba 100644
--- a/webapp/src/components/properties/user/user.tsx
+++ b/webapp/src/properties/person/person.tsx
@@ -1,31 +1,25 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
 
-import React from 'react'
+import React, {useCallback} from 'react'
 import Select from 'react-select'
 import {CSSObject} from '@emotion/serialize'
 
-import {Utils} from '../../../utils'
+import {Utils} from '../../utils'
+import {IUser} from '../../user'
+import {getBoardUsersList, getBoardUsers} from '../../store/users'
+import {useAppSelector} from '../../store/hooks'
+import mutator from '../../mutator'
+import {getSelectBaseStyle} from '../../theme'
+import {ClientConfig} from '../../config/clientConfig'
+import {getClientConfig} from '../../store/clientConfig'
 
-import {IUser} from '../../../user'
+import {PropertyProps} from '../types'
 
-import {getBoardUsersList, getBoardUsers} from '../../../store/users'
-import {useAppSelector} from '../../../store/hooks'
-
-import './user.scss'
-import {getSelectBaseStyle} from '../../../theme'
-import {ClientConfig} from '../../../config/clientConfig'
-import {getClientConfig} from '../../../store/clientConfig'
-import {propertyValueClassName} from '../../propertyValueUtils'
+import './person.scss'
 
 const imageURLForUser = (window as any).Components?.imageURLForUser
 
-type Props = {
-    value: string,
-    readonly: boolean,
-    onChange: (value: string) => void,
-}
-
 const selectStyles = {
     ...getSelectBaseStyle(),
     option: (provided: CSSObject, state: {isFocused: boolean}): CSSObject => ({
@@ -58,7 +52,9 @@ const selectStyles = {
     }),
 }
 
-const UserProperty = (props: Props): JSX.Element => {
+const Person = (props: PropertyProps): JSX.Element => {
+    const {card, board, propertyTemplate, propertyValue, readOnly} = props
+
     const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
 
     const formatOptionLabel = (user: any) => {
@@ -66,12 +62,12 @@ const UserProperty = (props: Props): JSX.Element => {
         if (imageURLForUser) {
             profileImg = imageURLForUser(user.id)
         }
-    
+
         return (
-            <div className='UserProperty-item'>
+            <div className='Person-item'>
                 {profileImg && (
                     <img
-                        alt='UserProperty-avatar'
+                        alt='Person-avatar'
                         src={profileImg}
                     />
                 )}
@@ -81,13 +77,14 @@ const UserProperty = (props: Props): JSX.Element => {
     }
 
     const boardUsersById = useAppSelector<{[key:string]: IUser}>(getBoardUsers)
+    const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate.id])
 
-    const user = boardUsersById[props.value]
+    const user = boardUsersById[propertyValue as string]
 
-    if (props.readonly) {
+    if (readOnly) {
         return (
-            <div className={`UserProperty ${propertyValueClassName({readonly: true})}`}>
-                {user ? formatOptionLabel(user) : props.value}
+            <div className={`Person ${props.property.valueClassName(true)}`}>
+                {user ? formatOptionLabel(user) : propertyValue}
             </div>
         )
     }
@@ -100,23 +97,23 @@ const UserProperty = (props: Props): JSX.Element => {
             isSearchable={true}
             isClearable={true}
             backspaceRemovesValue={true}
-            className={`UserProperty ${propertyValueClassName()}`}
+            className={`Person ${props.property.valueClassName(props.readOnly)}`}
             classNamePrefix={'react-select'}
             formatOptionLabel={formatOptionLabel}
             styles={selectStyles}
             placeholder={'Empty'}
             getOptionLabel={(o: IUser) => o.username}
             getOptionValue={(a: IUser) => a.id}
-            value={boardUsersById[props.value] || null}
+            value={boardUsersById[propertyValue as string] || null}
             onChange={(item, action) => {
                 if (action.action === 'select-option') {
-                    props.onChange(item?.id || '')
+                    onChange(item?.id || '')
                 } else if (action.action === 'clear') {
-                    props.onChange('')
+                    onChange('')
                 }
             }}
         />
     )
 }
 
-export default UserProperty
+export default Person
diff --git a/webapp/src/properties/person/property.tsx b/webapp/src/properties/person/property.tsx
new file mode 100644
index 000000000..e02efae2e
--- /dev/null
+++ b/webapp/src/properties/person/property.tsx
@@ -0,0 +1,12 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import Person from './person'
+
+export default class PersonProperty extends PropertyType {
+    Editor = Person
+    name = 'Person'
+    type = 'person' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Person', defaultMessage: 'Person'})
+}
diff --git a/webapp/src/properties/phone/phone.tsx b/webapp/src/properties/phone/phone.tsx
new file mode 100644
index 000000000..e06bb3ff0
--- /dev/null
+++ b/webapp/src/properties/phone/phone.tsx
@@ -0,0 +1,17 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+import {PropertyProps} from '../types'
+import BaseTextEditor from '../baseTextEditor'
+
+const Phone = (props: PropertyProps): JSX.Element => {
+    return (
+        <BaseTextEditor
+            {...props}
+            validator={() => true}
+        />
+    )
+}
+export default Phone
diff --git a/webapp/src/properties/phone/property.tsx b/webapp/src/properties/phone/property.tsx
new file mode 100644
index 000000000..b741e002e
--- /dev/null
+++ b/webapp/src/properties/phone/property.tsx
@@ -0,0 +1,14 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Phone from './phone'
+
+export default class PhoneProperty extends PropertyType {
+    Editor = Phone
+    name = 'Phone'
+    type = 'phone' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Phone', defaultMessage: 'Phone'})
+    canFilter = true
+    filterValueType = 'text' as FilterValueType
+}
diff --git a/webapp/src/components/properties/select/__snapshots__/select.test.tsx.snap b/webapp/src/properties/select/__snapshots__/select.test.tsx.snap
similarity index 81%
rename from webapp/src/components/properties/select/__snapshots__/select.test.tsx.snap
rename to webapp/src/properties/select/__snapshots__/select.test.tsx.snap
index 66eacbd79..ab208249e 100644
--- a/webapp/src/components/properties/select/__snapshots__/select.test.tsx.snap
+++ b/webapp/src/properties/select/__snapshots__/select.test.tsx.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`components/properties/select shows empty placeholder 1`] = `
+exports[`properties/select shows empty placeholder 1`] = `
 <div>
   <div
     class="octo-propertyvalue octo-propertyvalue--readonly"
@@ -20,7 +20,7 @@ exports[`components/properties/select shows empty placeholder 1`] = `
 </div>
 `;
 
-exports[`components/properties/select shows the selected option 1`] = `
+exports[`properties/select shows the selected option 1`] = `
 <div>
   <div
     class="octo-propertyvalue octo-propertyvalue--readonly"
diff --git a/webapp/src/properties/select/property.tsx b/webapp/src/properties/select/property.tsx
new file mode 100644
index 000000000..4b7e0afb5
--- /dev/null
+++ b/webapp/src/properties/select/property.tsx
@@ -0,0 +1,35 @@
+import {IntlShape} from 'react-intl'
+
+import {IPropertyTemplate} from '../../blocks/board'
+import {Card} from '../../blocks/card'
+import {Utils} from '../../utils'
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Select from './select'
+
+export default class SelectProperty extends PropertyType {
+    Editor = Select
+    name = 'Select'
+    type = 'select' as PropertyTypeEnum
+    canGroup = true
+    canFilter = true
+    filterValueType = 'options' as FilterValueType
+
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Select', defaultMessage: 'Select'})
+
+    displayValue = (propertyValue: string | string[] | undefined, card: Card, propertyTemplate: IPropertyTemplate) => {
+        if (propertyValue) {
+            const option = propertyTemplate.options.find((o) => o.id === propertyValue)
+            if (!option) {
+                Utils.assertFailure(`Invalid select option ID ${propertyValue}, block.title: ${card.title}`)
+            }
+            return option?.value || '(Unknown)'
+        }
+        return ''
+    }
+
+    valueLength = (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, _: IntlShape, fontDescriptor: string): number => {
+        const displayValue = this.displayValue(value, card, template) || ''
+        return Utils.getTextWidth(displayValue.toString().toUpperCase(), fontDescriptor)
+    }
+}
diff --git a/webapp/src/components/properties/select/select.test.tsx b/webapp/src/properties/select/select.test.tsx
similarity index 67%
rename from webapp/src/components/properties/select/select.test.tsx
rename to webapp/src/properties/select/select.test.tsx
index 86ff84f84..f0a4053df 100644
--- a/webapp/src/components/properties/select/select.test.tsx
+++ b/webapp/src/properties/select/select.test.tsx
@@ -3,15 +3,22 @@
 import React from 'react'
 import {render, screen} from '@testing-library/react'
 import '@testing-library/jest-dom'
+import {mocked} from 'jest-mock'
 
 import userEvent from '@testing-library/user-event'
 
-import {IPropertyTemplate} from '../../../blocks/board'
+import {IPropertyTemplate, createBoard} from '../../blocks/board'
+import {createCard} from '../../blocks/card'
 
-import {wrapIntl} from '../../../testUtils'
+import {wrapIntl} from '../../testUtils'
+import mutator from '../../mutator'
 
+import SelectProperty from './property'
 import Select from './select'
 
+jest.mock('../../mutator')
+const mockedMutator = mocked(mutator, true)
+
 function selectPropertyTemplate(): IPropertyTemplate {
     return {
         id: 'select-template',
@@ -37,20 +44,12 @@ function selectPropertyTemplate(): IPropertyTemplate {
     }
 }
 
-function selectCallbacks() {
-    return {
-        onCreate: jest.fn(),
-        onChange: jest.fn(),
-        onChangeColor: jest.fn(),
-        onDeleteOption: jest.fn(),
-        onDeleteValue: jest.fn(),
-    }
-}
-
-describe('components/properties/select', () => {
+describe('properties/select', () => {
     const nonEditableSelectTestId = 'select-non-editable'
 
     const clearButton = () => screen.queryByRole('button', {name: /clear/i})
+    const board = createBoard()
+    const card = createCard()
 
     it('shows the selected option', () => {
         const propertyTemplate = selectPropertyTemplate()
@@ -58,11 +57,13 @@ describe('components/properties/select', () => {
 
         const {container} = render(wrapIntl(
             <Select
-                emptyValue={''}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
                 propertyTemplate={propertyTemplate}
                 propertyValue={option.id}
-                isEditable={false}
-                {...selectCallbacks()}
+                readOnly={true}
+                showEmptyPlaceholder={false}
             />,
         ))
 
@@ -78,11 +79,13 @@ describe('components/properties/select', () => {
 
         const {container} = render(wrapIntl(
             <Select
-                emptyValue={emptyValue}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
+                showEmptyPlaceholder={true}
                 propertyTemplate={propertyTemplate}
                 propertyValue={''}
-                isEditable={false}
-                {...selectCallbacks()}
+                readOnly={true}
             />,
         ))
 
@@ -98,11 +101,13 @@ describe('components/properties/select', () => {
 
         render(wrapIntl(
             <Select
-                emptyValue={''}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
                 propertyTemplate={propertyTemplate}
                 propertyValue={selected.id}
-                isEditable={true}
-                {...selectCallbacks()}
+                showEmptyPlaceholder={false}
+                readOnly={false}
             />,
         ))
 
@@ -123,16 +128,16 @@ describe('components/properties/select', () => {
     it('can select the option from menu', () => {
         const propertyTemplate = selectPropertyTemplate()
         const optionToSelect = propertyTemplate.options[2]
-        const onChange = jest.fn()
 
         render(wrapIntl(
             <Select
-                emptyValue={'Empty'}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
                 propertyTemplate={propertyTemplate}
                 propertyValue={''}
-                isEditable={true}
-                {...selectCallbacks()}
-                onChange={onChange}
+                showEmptyPlaceholder={false}
+                readOnly={false}
             />,
         ))
 
@@ -140,22 +145,22 @@ describe('components/properties/select', () => {
         userEvent.click(screen.getByText(optionToSelect.value))
 
         expect(clearButton()).not.toBeInTheDocument()
-        expect(onChange).toHaveBeenCalledWith(optionToSelect.id)
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, optionToSelect.id)
     })
 
     it('can clear the selected option', () => {
         const propertyTemplate = selectPropertyTemplate()
         const selected = propertyTemplate.options[1]
-        const onDeleteValue = jest.fn()
 
         render(wrapIntl(
             <Select
-                emptyValue={'Empty'}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
                 propertyTemplate={propertyTemplate}
                 propertyValue={selected.id}
-                isEditable={true}
-                {...selectCallbacks()}
-                onDeleteValue={onDeleteValue}
+                showEmptyPlaceholder={false}
+                readOnly={false}
             />,
         ))
 
@@ -165,29 +170,32 @@ describe('components/properties/select', () => {
         expect(clear).toBeInTheDocument()
         userEvent.click(clear!)
 
-        expect(onDeleteValue).toHaveBeenCalled()
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, '')
     })
 
     it('can create new option', () => {
         const propertyTemplate = selectPropertyTemplate()
         const initialOption = propertyTemplate.options[0]
         const newOption = 'new-option'
-        const onCreate = jest.fn()
 
         render(wrapIntl(
             <Select
-                emptyValue={'Empty'}
+                property={new SelectProperty()}
+                board={{...board}}
+                card={{...card}}
                 propertyTemplate={propertyTemplate}
                 propertyValue={initialOption.id}
-                isEditable={true}
-                {...selectCallbacks()}
-                onCreate={onCreate}
+                showEmptyPlaceholder={false}
+                readOnly={false}
             />,
         ))
 
+        mockedMutator.insertPropertyOption.mockResolvedValue()
+
         userEvent.click(screen.getByTestId(nonEditableSelectTestId))
         userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), `${newOption}{enter}`)
 
-        expect(onCreate).toHaveBeenCalledWith(newOption)
+        expect(mockedMutator.insertPropertyOption).toHaveBeenCalledWith(board.id, board.cardProperties, propertyTemplate, expect.objectContaining({value: newOption}), 'add property option')
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, 'option-3')
     })
 })
diff --git a/webapp/src/properties/select/select.tsx b/webapp/src/properties/select/select.tsx
new file mode 100644
index 000000000..4533636bc
--- /dev/null
+++ b/webapp/src/properties/select/select.tsx
@@ -0,0 +1,75 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useState, useCallback} from 'react'
+import {useIntl} from 'react-intl'
+
+import {IPropertyOption} from '../../blocks/board'
+
+import Label from '../../widgets/label'
+import {Utils, IDType} from '../../utils'
+import mutator from '../../mutator'
+import ValueSelector from '../../widgets/valueSelector'
+
+import {PropertyProps} from '../types'
+
+const SelectProperty = (props: PropertyProps) => {
+    const {propertyValue, propertyTemplate, board, card} = props
+    const intl = useIntl()
+
+    const [open, setOpen] = useState(false)
+    const isEditable = !props.readOnly && Boolean(board)
+
+    const onCreate = useCallback((newValue) => {
+        const option: IPropertyOption = {
+            id: Utils.createGuid(IDType.BlockID),
+            value: newValue,
+            color: 'propColorDefault',
+        }
+        mutator.insertPropertyOption(board.id, board.cardProperties, propertyTemplate, option, 'add property option').then(() => {
+            mutator.changePropertyValue(board.id, card, propertyTemplate.id, option.id)
+        })
+    }, [board, board.id, props.card, propertyTemplate.id])
+
+    const emptyDisplayValue = props.showEmptyPlaceholder ? intl.formatMessage({id: 'PropertyValueElement.empty', defaultMessage: 'Empty'}) : ''
+
+    const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate])
+    const onChangeColor = useCallback((option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(board.id, board.cardProperties, propertyTemplate, option, colorId), [board, propertyTemplate])
+    const onDeleteOption = useCallback((option: IPropertyOption) => mutator.deletePropertyOption(board.id, board.cardProperties, propertyTemplate, option), [board, propertyTemplate])
+    const onDeleteValue = useCallback(() => mutator.changePropertyValue(board.id, card, propertyTemplate.id, ''), [card, propertyTemplate.id])
+
+    const option = propertyTemplate.options.find((o: IPropertyOption) => o.id === propertyValue)
+    const propertyColorCssClassName = option?.color || ''
+    const displayValue = option?.value
+    const finalDisplayValue = displayValue || emptyDisplayValue
+
+    if (!isEditable || !open) {
+        return (
+            <div
+                className={props.property.valueClassName(!isEditable)}
+                data-testid='select-non-editable'
+                tabIndex={0}
+                onClick={() => setOpen(true)}
+            >
+                <Label color={displayValue ? propertyColorCssClassName : 'empty'}>
+                    <span className='Label-text'>{finalDisplayValue}</span>
+                </Label>
+            </div>
+        )
+    }
+    return (
+        <ValueSelector
+            emptyValue={emptyDisplayValue}
+            options={propertyTemplate.options}
+            value={propertyTemplate.options.find((p: IPropertyOption) => p.id === propertyValue)}
+            onCreate={onCreate}
+            onChange={onChange}
+            onChangeColor={onChangeColor}
+            onDeleteOption={onDeleteOption}
+            onDeleteValue={onDeleteValue}
+            onBlur={() => setOpen(false)}
+        />
+    )
+}
+
+export default React.memo(SelectProperty)
diff --git a/webapp/src/properties/text/property.tsx b/webapp/src/properties/text/property.tsx
new file mode 100644
index 000000000..99c619f2e
--- /dev/null
+++ b/webapp/src/properties/text/property.tsx
@@ -0,0 +1,14 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Text from './text'
+
+export default class TextProperty extends PropertyType {
+    Editor = Text
+    name = 'Text'
+    type = 'text' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Text', defaultMessage: 'Text'})
+    canFilter = true
+    filterValueType = 'text' as FilterValueType
+}
diff --git a/webapp/src/properties/text/text.tsx b/webapp/src/properties/text/text.tsx
new file mode 100644
index 000000000..e5b40fa64
--- /dev/null
+++ b/webapp/src/properties/text/text.tsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react'
+
+
+import {PropertyProps} from '../types'
+import BaseTextEditor from '../baseTextEditor'
+
+const Text = (props: PropertyProps): JSX.Element => {
+    return (
+        <BaseTextEditor
+            {...props}
+            validator={() => true}
+            spellCheck={true}
+        />
+    )
+}
+export default Text
diff --git a/webapp/src/properties/types.tsx b/webapp/src/properties/types.tsx
new file mode 100644
index 000000000..cd2a62258
--- /dev/null
+++ b/webapp/src/properties/types.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import {IntlShape} from 'react-intl'
+
+import {Card} from '../blocks/card'
+import {Board, IPropertyTemplate, PropertyTypeEnum} from '../blocks/board'
+import {Options} from '../components/calculations/options'
+import {Utils} from '../utils'
+
+const hashSignToken = '___hash_sign___'
+function encodeText(text: string): string {
+    return text.replace(/"/g, '""').replace(/#/g, hashSignToken)
+}
+
+export type {PropertyTypeEnum} from '../blocks/board'
+
+export type FilterValueType = 'none'|'options'|'boolean'|'text'
+
+export type FilterCondition = {
+    id: string
+    label: string
+}
+
+export type PropertyProps = {
+    property: PropertyType,
+    card: Card,
+    board: Board,
+    readOnly: boolean,
+    propertyValue: string | string[],
+    propertyTemplate: IPropertyTemplate,
+    showEmptyPlaceholder: boolean,
+}
+
+export abstract class PropertyType {
+    isDate = false
+    canGroup = false
+    canFilter = false
+    filterValueType: FilterValueType = "none"
+    isReadOnly = false
+    calculationOptions = [Options.none, Options.count, Options.countEmpty,
+        Options.countNotEmpty, Options.percentEmpty, Options.percentNotEmpty,
+        Options.countValue, Options.countUniqueValue]
+    displayValue: (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, intl: IntlShape) => string | string[] | undefined
+    getDateFrom: (value: string | string[] | undefined, card: Card) => Date
+    getDateTo: (value: string | string[] | undefined, card: Card) => Date
+    valueLength: (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, intl: IntlShape, fontDescriptor: string, perItemPadding?: number) => number
+
+    constructor() {
+        this.displayValue = (value: string | string[] | undefined) => value
+        this.getDateFrom = (_: string | string[] | undefined, card: Card) => new Date(card.createAt || 0)
+        this.getDateTo = (_: string | string[] | undefined, card: Card) => new Date(card.createAt || 0)
+        this.valueLength = (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, intl: IntlShape, fontDescriptor: string): number => {
+            const displayValue = this.displayValue(value, card, template, intl) || ''
+            return Utils.getTextWidth(displayValue.toString(), fontDescriptor)
+        }
+    }
+
+    exportValue = (value: string | string[] | undefined, card: Card, template: IPropertyTemplate, intl: IntlShape): string => {
+        const displayValue = this.displayValue(value, card, template, intl)
+        return `"${encodeText(displayValue as string)}"`
+    }
+
+
+    valueClassName = (readonly: boolean): string => {
+        return `octo-propertyvalue${readonly ? ' octo-propertyvalue--readonly' : ''}`
+    }
+
+    abstract Editor: React.FunctionComponent<PropertyProps>
+    abstract name: string
+    abstract type: PropertyTypeEnum
+    abstract displayName: (intl: IntlShape) => string
+}
diff --git a/webapp/src/properties/unknown/property.tsx b/webapp/src/properties/unknown/property.tsx
new file mode 100644
index 000000000..0cb95d94f
--- /dev/null
+++ b/webapp/src/properties/unknown/property.tsx
@@ -0,0 +1,11 @@
+import {IntlShape} from 'react-intl'
+
+import Text from '../text/text'
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+export default class UnkownProperty extends PropertyType {
+    Editor = Text
+    name = 'Text'
+    type = 'unknown' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Unknown', defaultMessage: 'Unknown'})
+}
diff --git a/webapp/src/properties/updatedBy/__snapshots__/updatedBy.test.tsx.snap b/webapp/src/properties/updatedBy/__snapshots__/updatedBy.test.tsx.snap
new file mode 100644
index 000000000..aa0c81af0
--- /dev/null
+++ b/webapp/src/properties/updatedBy/__snapshots__/updatedBy.test.tsx.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`properties/updatedBy should match snapshot 1`] = `
+<div>
+  <div
+    class="Person octo-propertyvalue octo-propertyvalue--readonly"
+  >
+    <div
+      class="Person-item"
+    >
+      username_1
+    </div>
+  </div>
+</div>
+`;
diff --git a/webapp/src/properties/updatedBy/property.tsx b/webapp/src/properties/updatedBy/property.tsx
new file mode 100644
index 000000000..a2324f5e6
--- /dev/null
+++ b/webapp/src/properties/updatedBy/property.tsx
@@ -0,0 +1,13 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import UpdatedBy from './updatedBy'
+
+export default class UpdatedByProperty extends PropertyType {
+    Editor = UpdatedBy
+    name = 'Last Modified By'
+    type = 'updatedBy' as PropertyTypeEnum
+    isReadOnly = true
+    displayName = (intl: IntlShape) => intl.formatMessage({id: 'PropertyType.UpdatedBy', defaultMessage: 'Last updated by'})
+}
diff --git a/webapp/src/components/properties/lastModifiedBy/lastModifiedBy.scss b/webapp/src/properties/updatedBy/updatedBy.scss
similarity index 100%
rename from webapp/src/components/properties/lastModifiedBy/lastModifiedBy.scss
rename to webapp/src/properties/updatedBy/updatedBy.scss
diff --git a/webapp/src/components/properties/lastModifiedBy/lastModifiedBy.test.tsx b/webapp/src/properties/updatedBy/updatedBy.test.tsx
similarity index 71%
rename from webapp/src/components/properties/lastModifiedBy/lastModifiedBy.test.tsx
rename to webapp/src/properties/updatedBy/updatedBy.test.tsx
index 87aa0da47..332454ac5 100644
--- a/webapp/src/components/properties/lastModifiedBy/lastModifiedBy.test.tsx
+++ b/webapp/src/properties/updatedBy/updatedBy.test.tsx
@@ -7,16 +7,17 @@ import {Provider as ReduxProvider} from 'react-redux'
 import {render} from '@testing-library/react'
 import configureStore from 'redux-mock-store'
 
-import {createCard} from '../../../blocks/card'
-import {IUser} from '../../../user'
+import {createCard} from '../../blocks/card'
+import {IUser} from '../../user'
 
-import {createBoard} from '../../../blocks/board'
+import {createBoard, IPropertyTemplate} from '../../blocks/board'
 
-import {createCommentBlock} from '../../../blocks/commentBlock'
+import {createCommentBlock} from '../../blocks/commentBlock'
 
-import LastModifiedBy from './lastModifiedBy'
+import UpdatedByProperty from './property'
+import UpdatedBy from './updatedBy'
 
-describe('components/properties/lastModifiedBy', () => {
+describe('properties/updatedBy', () => {
     test('should match snapshot', () => {
         const card = createCard()
         card.id = 'card-id-1'
@@ -51,9 +52,14 @@ describe('components/properties/lastModifiedBy', () => {
 
         const component = (
             <ReduxProvider store={store}>
-                <LastModifiedBy
+                <UpdatedBy
+                    property={new UpdatedByProperty()}
                     card={card}
                     board={board}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    propertyValue={''}
+                    readOnly={false}
+                    showEmptyPlaceholder={false}
                 />
             </ReduxProvider>
         )
diff --git a/webapp/src/components/properties/lastModifiedBy/lastModifiedBy.tsx b/webapp/src/properties/updatedBy/updatedBy.tsx
similarity index 51%
rename from webapp/src/components/properties/lastModifiedBy/lastModifiedBy.tsx
rename to webapp/src/properties/updatedBy/updatedBy.tsx
index 3234e5f16..b177e9857 100644
--- a/webapp/src/components/properties/lastModifiedBy/lastModifiedBy.tsx
+++ b/webapp/src/properties/updatedBy/updatedBy.tsx
@@ -3,20 +3,15 @@
 
 import React from 'react'
 
-import {Card} from '../../../blocks/card'
-import {Board} from '../../../blocks/board'
-import {Block} from '../../../blocks/block'
-import {useAppSelector} from '../../../store/hooks'
-import {getLastCardContent} from '../../../store/contents'
-import {getLastCardComment} from '../../../store/comments'
-import UserProperty from '../user/user'
+import {Block} from '../../blocks/block'
+import {useAppSelector} from '../../store/hooks'
+import {getLastCardContent} from '../../store/contents'
+import {getLastCardComment} from '../../store/comments'
+import Person from '../person/person'
 
-type Props = {
-    card: Card,
-    board?: Board,
-}
+import {PropertyProps} from '../types'
 
-const LastModifiedBy = (props: Props): JSX.Element => {
+const LastModifiedBy = (props: PropertyProps): JSX.Element => {
     const lastContent = useAppSelector(getLastCardContent(props.card.id || '')) as Block
     const lastComment = useAppSelector(getLastCardComment(props.card.id)) as Block
 
@@ -29,10 +24,10 @@ const LastModifiedBy = (props: Props): JSX.Element => {
     }
 
     return (
-        <UserProperty
-            value={latestBlock.modifiedBy}
-            readonly={true} // created by is an immutable property, so will always be readonly
-            onChange={() => { }} // since created by is immutable, we don't need to handle onChange
+        <Person
+            {...props}
+            propertyValue={latestBlock.modifiedBy}
+            readOnly={true} // created by is an immutable property, so will always be readonly
         />
     )
 }
diff --git a/webapp/src/properties/updatedTime/__snapshots__/updatedTime.test.tsx.snap b/webapp/src/properties/updatedTime/__snapshots__/updatedTime.test.tsx.snap
new file mode 100644
index 000000000..49d212080
--- /dev/null
+++ b/webapp/src/properties/updatedTime/__snapshots__/updatedTime.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`properties/updatedTime should match snapshot 1`] = `
+<div>
+  <div
+    class="UpdatedTime octo-propertyvalue octo-propertyvalue--readonly"
+  >
+    June 15, 2021, 4:22 PM
+  </div>
+</div>
+`;
diff --git a/webapp/src/properties/updatedTime/property.tsx b/webapp/src/properties/updatedTime/property.tsx
new file mode 100644
index 000000000..d9ca8371b
--- /dev/null
+++ b/webapp/src/properties/updatedTime/property.tsx
@@ -0,0 +1,25 @@
+import {IntlShape} from 'react-intl'
+
+import {Options} from '../../components/calculations/options'
+import {IPropertyTemplate} from '../../blocks/board'
+import {Card} from '../../blocks/card'
+import {Utils} from '../../utils'
+import {PropertyType, PropertyTypeEnum} from '../types'
+
+import UpdatedTime from './updatedTime'
+
+export default class UpdatedTimeProperty extends PropertyType {
+    Editor = UpdatedTime
+    name = 'Last Modified At'
+    type = 'updatedTime' as PropertyTypeEnum
+    isDate = true
+    isReadOnly = true
+    displayName = (intl: IntlShape) => intl.formatMessage({id: 'PropertyType.UpdatedTime', defaultMessage: 'Last updated time'})
+    calculationOptions = [Options.none, Options.count, Options.countEmpty,
+        Options.countNotEmpty, Options.percentEmpty, Options.percentNotEmpty,
+        Options.countValue, Options.countUniqueValue, Options.earliest,
+        Options.latest, Options.dateRange]
+    displayValue = (_1: string | string[] | undefined, card: Card, _2: IPropertyTemplate, intl: IntlShape) => Utils.displayDateTime(new Date(card.updateAt), intl)
+    getDateFrom = (_: string | string[] | undefined, card: Card) => new Date(card.updateAt || 0)
+    getDateTo = (_: string | string[] | undefined, card: Card) => new Date(card.updateAt || 0)
+}
diff --git a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.scss b/webapp/src/properties/updatedTime/updatedTime.scss
similarity index 71%
rename from webapp/src/components/properties/lastModifiedAt/lastModifiedAt.scss
rename to webapp/src/properties/updatedTime/updatedTime.scss
index ee1a78618..f2132d9ad 100644
--- a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.scss
+++ b/webapp/src/properties/updatedTime/updatedTime.scss
@@ -1,4 +1,4 @@
-.LastModifiedAt {
+.UpdatedTime {
     display: flex;
     align-items: center;
 }
diff --git a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.test.tsx b/webapp/src/properties/updatedTime/updatedTime.test.tsx
similarity index 63%
rename from webapp/src/components/properties/lastModifiedAt/lastModifiedAt.test.tsx
rename to webapp/src/properties/updatedTime/updatedTime.test.tsx
index 9ab078db5..c2f8cf38d 100644
--- a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.test.tsx
+++ b/webapp/src/properties/updatedTime/updatedTime.test.tsx
@@ -7,14 +7,16 @@ import {Provider as ReduxProvider} from 'react-redux'
 import {render} from '@testing-library/react'
 import configureStore from 'redux-mock-store'
 
-import {createCard} from '../../../blocks/card'
-import {wrapIntl} from '../../../testUtils'
+import {createCard} from '../../blocks/card'
+import {IPropertyTemplate, Board} from '../../blocks/board'
+import {wrapIntl} from '../../testUtils'
 
-import {createCommentBlock} from '../../../blocks/commentBlock'
+import {createCommentBlock} from '../../blocks/commentBlock'
 
-import LastModifiedAt from './lastModifiedAt'
+import UpdatedTimeProperty from './property'
+import UpdatedTime from './updatedTime'
 
-describe('components/properties/lastModifiedAt', () => {
+describe('properties/updatedTime', () => {
     test('should match snapshot', () => {
         const card = createCard()
         card.id = 'card-id-1'
@@ -40,7 +42,15 @@ describe('components/properties/lastModifiedAt', () => {
 
         const component = wrapIntl(
             <ReduxProvider store={store}>
-                <LastModifiedAt card={card}/>
+                <UpdatedTime
+                    property={new UpdatedTimeProperty()}
+                    card={card}
+                    board={{} as Board}
+                    propertyTemplate={{} as IPropertyTemplate}
+                    propertyValue={''}
+                    readOnly={false}
+                    showEmptyPlaceholder={false}
+                />
             </ReduxProvider>,
         )
 
diff --git a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.tsx b/webapp/src/properties/updatedTime/updatedTime.tsx
similarity index 56%
rename from webapp/src/components/properties/lastModifiedAt/lastModifiedAt.tsx
rename to webapp/src/properties/updatedTime/updatedTime.tsx
index 884bd3ae9..e72308eba 100644
--- a/webapp/src/components/properties/lastModifiedAt/lastModifiedAt.tsx
+++ b/webapp/src/properties/updatedTime/updatedTime.tsx
@@ -5,20 +5,16 @@ import React from 'react'
 
 import {useIntl} from 'react-intl'
 
-import {Card} from '../../../blocks/card'
-import {Block} from '../../../blocks/block'
-import {Utils} from '../../../utils'
-import {useAppSelector} from '../../../store/hooks'
-import {getLastCardContent} from '../../../store/contents'
-import {getLastCardComment} from '../../../store/comments'
-import {propertyValueClassName} from '../../propertyValueUtils'
-import './lastModifiedAt.scss'
+import {Block} from '../../blocks/block'
+import {Utils} from '../../utils'
+import {useAppSelector} from '../../store/hooks'
+import {getLastCardContent} from '../../store/contents'
+import {getLastCardComment} from '../../store/comments'
+import './updatedTime.scss'
 
-type Props = {
-    card: Card,
-}
+import {PropertyProps} from '../types'
 
-const LastModifiedAt = (props: Props): JSX.Element => {
+const UpdatedTime = (props: PropertyProps): JSX.Element => {
     const intl = useIntl()
     const lastContent = useAppSelector(getLastCardContent(props.card.id || '')) as Block
     const lastComment = useAppSelector(getLastCardComment(props.card.id)) as Block
@@ -32,10 +28,10 @@ const LastModifiedAt = (props: Props): JSX.Element => {
     }
 
     return (
-        <div className={`LastModifiedAt ${propertyValueClassName({readonly: true})}`}>
+        <div className={`UpdatedTime ${props.property.valueClassName(true)}`}>
             {Utils.displayDateTime(new Date(latestBlock.updateAt), intl)}
         </div>
     )
 }
 
-export default LastModifiedAt
+export default UpdatedTime
diff --git a/webapp/src/components/properties/link/__snapshots__/link.test.tsx.snap b/webapp/src/properties/url/__snapshots__/url.test.tsx.snap
similarity index 83%
rename from webapp/src/components/properties/link/__snapshots__/link.test.tsx.snap
rename to webapp/src/properties/url/__snapshots__/url.test.tsx.snap
index af65fb3fd..036176dfb 100644
--- a/webapp/src/components/properties/link/__snapshots__/link.test.tsx.snap
+++ b/webapp/src/properties/url/__snapshots__/url.test.tsx.snap
@@ -1,12 +1,13 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`components/properties/link should match snapshot for link with empty url 1`] = `
+exports[`properties/link should match snapshot for link with empty url 1`] = `
 <div>
   <div
     class="URLProperty"
   >
     <input
       class="Editable octo-propertyvalue"
+      placeholder=""
       style="width: 5px;"
       title=""
       value=""
@@ -15,7 +16,7 @@ exports[`components/properties/link should match snapshot for link with empty ur
 </div>
 `;
 
-exports[`components/properties/link should match snapshot for link with non-empty url 1`] = `
+exports[`properties/link should match snapshot for link with non-empty url 1`] = `
 <div>
   <div
     class="URLProperty octo-propertyvalue"
@@ -52,7 +53,7 @@ exports[`components/properties/link should match snapshot for link with non-empt
 </div>
 `;
 
-exports[`components/properties/link should match snapshot for readonly link with non-empty url 1`] = `
+exports[`properties/link should match snapshot for readonly link with non-empty url 1`] = `
 <div>
   <div
     class="URLProperty octo-propertyvalue octo-propertyvalue--readonly"
diff --git a/webapp/src/properties/url/property.tsx b/webapp/src/properties/url/property.tsx
new file mode 100644
index 000000000..d82f9c6a6
--- /dev/null
+++ b/webapp/src/properties/url/property.tsx
@@ -0,0 +1,14 @@
+import {IntlShape} from 'react-intl'
+
+import {PropertyType, PropertyTypeEnum, FilterValueType} from '../types'
+
+import Url from './url'
+
+export default class UrlProperty extends PropertyType {
+    Editor = Url
+    name = 'Url'
+    type = 'url' as PropertyTypeEnum
+    displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.Url', defaultMessage: 'URL'})
+    canFilter = true
+    filterValueType = 'text' as FilterValueType
+}
diff --git a/webapp/src/components/properties/link/link.scss b/webapp/src/properties/url/url.scss
similarity index 100%
rename from webapp/src/components/properties/link/link.scss
rename to webapp/src/properties/url/url.scss
diff --git a/webapp/src/components/properties/link/link.test.tsx b/webapp/src/properties/url/url.test.tsx
similarity index 52%
rename from webapp/src/components/properties/link/link.test.tsx
rename to webapp/src/properties/url/url.test.tsx
index 642415a1d..549902e68 100644
--- a/webapp/src/components/properties/link/link.test.tsx
+++ b/webapp/src/properties/url/url.test.tsx
@@ -1,7 +1,7 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
 
-import React, {useState} from 'react'
+import React from 'react'
 import {render, screen} from '@testing-library/react'
 
 import {mocked} from 'jest-mock'
@@ -9,43 +9,42 @@ import {mocked} from 'jest-mock'
 import '@testing-library/jest-dom'
 import userEvent from '@testing-library/user-event'
 
-import {wrapIntl} from '../../../testUtils'
-import {Utils} from '../../../utils'
-import {sendFlashMessage} from '../../flashMessages'
+import {wrapIntl} from '../../testUtils'
+import {TestBlockFactory} from '../../test/testBlockFactory'
+import {Utils} from '../../utils'
+import {sendFlashMessage} from '../../components/flashMessages'
+import mutator from '../../mutator'
 
-import Link from './link'
+import UrlProperty from './property'
+import Url from './url'
 
-jest.mock('../../flashMessages')
+jest.mock('../../components/flashMessages')
+jest.mock('../../mutator')
 
 const mockedCopy = jest.spyOn(Utils, 'copyTextToClipboard').mockImplementation(() => true)
 const mockedSendFlashMessage = mocked(sendFlashMessage, true)
+const mockedMutator = mocked(mutator, true)
 
-describe('components/properties/link', () => {
+describe('properties/link', () => {
     beforeEach(jest.clearAllMocks)
 
-    const linkCallbacks = {
-        onChange: jest.fn(),
-        onSave: jest.fn(),
-        onCancel: jest.fn(),
-        validator: jest.fn(() => true),
-    }
-
-    const LinkWrapper = (props: {url: string}) => {
-        const [value, setValue] = useState(props.url)
-        return (
-            <Link
-                {...linkCallbacks}
-                value={value}
-                onChange={(text) => setValue(text)}
-            />
-        )
+    const board = TestBlockFactory.createBoard()
+    const card = TestBlockFactory.createCard()
+    const propertyTemplate = board.cardProperties[0]
+    const baseData = {
+        property: new UrlProperty(),
+        card,
+        board,
+        propertyTemplate,
+        readOnly: false,
+        showEmptyPlaceholder: false,
     }
 
     it('should match snapshot for link with empty url', () => {
         const {container} = render(wrapIntl((
-            <Link
-                {...linkCallbacks}
-                value=''
+            <Url
+                {...baseData}
+                propertyValue=''
             />
         )))
         expect(container).toMatchSnapshot()
@@ -53,9 +52,9 @@ describe('components/properties/link', () => {
 
     it('should match snapshot for link with non-empty url', () => {
         const {container} = render(wrapIntl((
-            <Link
-                {...linkCallbacks}
-                value='https://github.com/mattermost/focalboard'
+            <Url
+                {...baseData}
+                propertyValue='https://github.com/mattermost/focalboard'
             />
         )))
         expect(container).toMatchSnapshot()
@@ -63,46 +62,39 @@ describe('components/properties/link', () => {
 
     it('should match snapshot for readonly link with non-empty url', () => {
         const {container} = render(wrapIntl((
-            <Link
-                {...linkCallbacks}
-                value='https://github.com/mattermost/focalboard'
-                readonly={true}
+            <Url
+                {...baseData}
+                propertyValue='https://github.com/mattermost/focalboard'
+                readOnly={true}
             />
         )))
         expect(container).toMatchSnapshot()
     })
 
     it('should change to link after entering url', () => {
-        render(wrapIntl(<LinkWrapper url=''/>))
+        render(wrapIntl(<Url {...baseData} propertyValue=''/>))
 
         const url = 'https://mattermost.com'
         const input = screen.getByRole('textbox')
         userEvent.type(input, `${url}{enter}`)
 
-        const link = screen.getByRole('link')
-        expect(link).toHaveAttribute('href', url)
-        expect(link).toHaveTextContent(url)
-        expect(screen.getByRole('button', {name: 'Edit'})).toBeInTheDocument()
-        expect(screen.getByRole('button', {name: 'Copy'})).toBeInTheDocument()
-        expect(linkCallbacks.onSave).toHaveBeenCalled()
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, url)
     })
 
     it('should allow to edit link url', () => {
-        render(wrapIntl(<LinkWrapper url='https://mattermost.com'/>))
+        render(wrapIntl(<Url {...baseData} propertyValue='https://mattermost.com'/>))
 
         screen.getByRole('button', {name: 'Edit'}).click()
         const newURL = 'https://github.com/mattermost'
         const input = screen.getByRole('textbox')
         userEvent.clear(input)
         userEvent.type(input, `${newURL}{enter}`)
-        const link = screen.getByRole('link')
-        expect(link).toHaveAttribute('href', newURL)
-        expect(link).toHaveTextContent(newURL)
+        expect(mockedMutator.changePropertyValue).toHaveBeenCalledWith(board.id, card, propertyTemplate.id, newURL)
     })
 
     it('should allow to copy url', () => {
         const url = 'https://mattermost.com'
-        render(wrapIntl(<LinkWrapper url={url}/>))
+        render(wrapIntl(<Url {...baseData} propertyValue={url}/>))
         screen.getByRole('button', {name: 'Copy'}).click()
         expect(mockedCopy).toHaveBeenCalledWith(url)
         expect(mockedSendFlashMessage).toHaveBeenCalledWith({content: 'Copied!', severity: 'high'})
diff --git a/webapp/src/properties/url/url.tsx b/webapp/src/properties/url/url.tsx
new file mode 100644
index 000000000..f3640143c
--- /dev/null
+++ b/webapp/src/properties/url/url.tsx
@@ -0,0 +1,121 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useEffect, useRef, useState, useCallback} from 'react'
+import {useIntl} from 'react-intl'
+
+import Editable, {Focusable} from '../../widgets/editable'
+
+import {Utils} from '../../utils'
+import mutator from '../../mutator'
+import EditIcon from '../../widgets/icons/edit'
+import IconButton from '../../widgets/buttons/iconButton'
+import DuplicateIcon from '../../widgets/icons/duplicate'
+import {sendFlashMessage} from '../../components/flashMessages'
+
+import {PropertyProps} from '../types'
+
+import './url.scss'
+
+const URLProperty = (props: PropertyProps): JSX.Element => {
+    if(!props.propertyTemplate) {
+        return <></>
+    }
+
+    const [value, setValue] = useState(props.card.fields.properties[props.propertyTemplate.id || ''] || '')
+    const [isEditing, setIsEditing] = useState(false)
+    const isEmpty = !(props.propertyValue as string)?.trim()
+    const showEditable = !props.readOnly && (isEditing || isEmpty)
+    const editableRef = useRef<Focusable>(null)
+    const intl = useIntl()
+
+    const emptyDisplayValue = props.showEmptyPlaceholder ? intl.formatMessage({id: 'PropertyValueElement.empty', defaultMessage: 'Empty'}) : ''
+
+    const saveTextProperty = useCallback(() => {
+        if (value !== (props.card.fields.properties[props.propertyTemplate?.id || ''] || '')) {
+            mutator.changePropertyValue(props.board.id, props.card, props.propertyTemplate?.id || '', value)
+        }
+    }, [props.card, props.propertyTemplate, value])
+
+    const saveTextPropertyRef = useRef<() => void>(saveTextProperty)
+    saveTextPropertyRef.current = saveTextProperty
+
+    useEffect(() => {
+        return () => {
+            saveTextPropertyRef.current && saveTextPropertyRef.current()
+        }
+    }, [])
+
+    useEffect(() => {
+        if (isEditing) {
+            editableRef.current?.focus()
+        }
+    }, [isEditing])
+
+    if (showEditable) {
+        return (
+            <div className='URLProperty'>
+                <Editable
+                    className={props.property.valueClassName(props.readOnly)}
+                    ref={editableRef}
+                    placeholderText={emptyDisplayValue}
+                    value={value as string}
+                    autoExpand={true}
+                    readonly={props.readOnly}
+                    onChange={setValue}
+                    onSave={() => {
+                        setIsEditing(false)
+                        saveTextProperty()
+                    }}
+                    onCancel={() => {
+                        setIsEditing(false)
+                        setValue(props.propertyValue || '')
+                    }}
+                    onFocus={() => {
+                        setIsEditing(true)
+                    }}
+                    validator={() => {
+                        if (value === '') {
+                            return true
+                        }
+                        const urlRegexp = /(((.+:(?:\/\/)?)?(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/
+                        return urlRegexp.test(value as string)
+                    }}
+                />
+            </div>
+        )
+    }
+
+    return (
+        <div className={`URLProperty ${props.property.valueClassName(props.readOnly)}`}>
+            <a
+                className='link'
+                href={Utils.ensureProtocol((props.propertyValue as string).trim())}
+                target='_blank'
+                rel='noreferrer'
+                onClick={(event) => event.stopPropagation()}
+            >
+                {props.propertyValue}
+            </a>
+            {!props.readOnly &&
+            <IconButton
+                className='Button_Edit'
+                title={intl.formatMessage({id: 'URLProperty.edit', defaultMessage: 'Edit'})}
+                icon={<EditIcon/>}
+                onClick={() => setIsEditing(true)}
+            />}
+            <IconButton
+                className='Button_Copy'
+                title={intl.formatMessage({id: 'URLProperty.copy', defaultMessage: 'Copy'})}
+                icon={<DuplicateIcon/>}
+                onClick={(e) => {
+                    e.stopPropagation()
+                    Utils.copyTextToClipboard(props.propertyValue as string)
+                    sendFlashMessage({content: intl.formatMessage({id: 'URLProperty.copiedLink', defaultMessage: 'Copied!'}), severity: 'high'})
+                }}
+            />
+        </div>
+    )
+}
+
+export default URLProperty
diff --git a/webapp/src/widgets/propertyMenu.test.tsx b/webapp/src/widgets/propertyMenu.test.tsx
index fed6acb4e..3b5b8565f 100644
--- a/webapp/src/widgets/propertyMenu.test.tsx
+++ b/webapp/src/widgets/propertyMenu.test.tsx
@@ -6,6 +6,7 @@ import {fireEvent, render} from '@testing-library/react'
 import '@testing-library/jest-dom'
 
 import {wrapIntl} from '../testUtils'
+import propsRegistry from '../properties'
 
 import PropertyMenu from './propertyMenu'
 
@@ -22,7 +23,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'email of a person'}
-                propertyType={'email'}
+                propertyType={propsRegistry.get('email')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
@@ -37,7 +38,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'email of a person'}
-                propertyType={'email'}
+                propertyType={propsRegistry.get('email')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
@@ -53,7 +54,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'test-property'}
-                propertyType={'text'}
+                propertyType={propsRegistry.get('text')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
@@ -62,7 +63,7 @@ describe('widgets/PropertyMenu', () => {
         const input = getByDisplayValue(/test-property/i)
         fireEvent.change(input, {target: {value: 'changed name'}})
         fireEvent.blur(input)
-        expect(callback).toHaveBeenCalledWith('text', 'changed name')
+        expect(callback).toHaveBeenCalledWith(propsRegistry.get('text'), 'changed name')
     })
 
     test('handles type change event', async () => {
@@ -71,7 +72,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'test-property'}
-                propertyType={'text'}
+                propertyType={propsRegistry.get('text')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
@@ -89,7 +90,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'test-property'}
-                propertyType={'text'}
+                propertyType={propsRegistry.get('text')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
@@ -110,7 +111,7 @@ describe('widgets/PropertyMenu', () => {
             <PropertyMenu
                 propertyId={'id'}
                 propertyName={'test-property'}
-                propertyType={'text'}
+                propertyType={propsRegistry.get('text')}
                 onTypeAndNameChanged={callback}
                 onDelete={callback}
             />,
diff --git a/webapp/src/widgets/propertyMenu.tsx b/webapp/src/widgets/propertyMenu.tsx
index d2af19e63..10f185e9d 100644
--- a/webapp/src/widgets/propertyMenu.tsx
+++ b/webapp/src/widgets/propertyMenu.tsx
@@ -3,9 +3,9 @@
 import React from 'react'
 import {useIntl, IntlShape} from 'react-intl'
 
-import {PropertyType} from '../blocks/board'
-import {Utils} from '../utils'
 import Menu from '../widgets/menu'
+import propsRegistry from '../properties'
+import {PropertyType} from '../properties/types'
 import './propertyMenu.scss'
 
 type Props = {
@@ -16,50 +16,10 @@ type Props = {
     onDelete: (id: string) => void
 }
 
-export function typeDisplayName(intl: IntlShape, type: PropertyType): string {
-    switch (type) {
-    case 'text': return intl.formatMessage({id: 'PropertyType.Text', defaultMessage: 'Text'})
-    case 'number': return intl.formatMessage({id: 'PropertyType.Number', defaultMessage: 'Number'})
-    case 'select': return intl.formatMessage({id: 'PropertyType.Select', defaultMessage: 'Select'})
-    case 'multiSelect': return intl.formatMessage({id: 'PropertyType.MultiSelect', defaultMessage: 'Multi select'})
-    case 'person': return intl.formatMessage({id: 'PropertyType.Person', defaultMessage: 'Person'})
-    case 'file': return intl.formatMessage({id: 'PropertyType.File', defaultMessage: 'File or media'})
-    case 'checkbox': return intl.formatMessage({id: 'PropertyType.Checkbox', defaultMessage: 'Checkbox'})
-    case 'url': return intl.formatMessage({id: 'PropertyType.URL', defaultMessage: 'URL'})
-    case 'email': return intl.formatMessage({id: 'PropertyType.Email', defaultMessage: 'Email'})
-    case 'phone': return intl.formatMessage({id: 'PropertyType.Phone', defaultMessage: 'Phone'})
-    case 'createdTime': return intl.formatMessage({id: 'PropertyType.CreatedTime', defaultMessage: 'Created time'})
-    case 'createdBy': return intl.formatMessage({id: 'PropertyType.CreatedBy', defaultMessage: 'Created by'})
-    case 'updatedTime': return intl.formatMessage({id: 'PropertyType.UpdatedTime', defaultMessage: 'Last updated time'})
-    case 'updatedBy': return intl.formatMessage({id: 'PropertyType.UpdatedBy', defaultMessage: 'Last updated by'})
-    case 'date': return intl.formatMessage({id: 'PropertyType.Date', defaultMessage: 'Date'})
-    default: {
-        Utils.assertFailure(`typeDisplayName, unhandled type: ${type}`)
-        return type
-    }
-    }
-}
 function typeMenuTitle(intl: IntlShape, type: PropertyType): string {
-    return `${intl.formatMessage({id: 'PropertyMenu.typeTitle', defaultMessage: 'Type'})}: ${typeDisplayName(intl, type)}`
+    return `${intl.formatMessage({id: 'PropertyMenu.typeTitle', defaultMessage: 'Type'})}: ${type.displayName(intl)}`
 }
 
-export const propertyTypesList: PropertyType[] = [
-    'text',
-    'number',
-    'email',
-    'phone',
-    'url',
-    'select',
-    'multiSelect',
-    'date',
-    'person',
-    'checkbox',
-    'createdTime',
-    'createdBy',
-    'updatedTime',
-    'updatedBy',
-]
-
 type TypesProps = {
     label: string
     onTypeSelected: (type: PropertyType) => void
@@ -76,12 +36,12 @@ export const PropertyTypes = (props: TypesProps): JSX.Element => {
             <Menu.Separator/>
 
             {
-                propertyTypesList.map((type) => (
+                propsRegistry.list().map((p) => (
                     <Menu.Text
-                        key={type}
-                        id={type}
-                        name={typeDisplayName(intl, type)}
-                        onClick={() => props.onTypeSelected(type)}
+                        key={p.type}
+                        id={p.type}
+                        name={p.displayName(intl)}
+                        onClick={() => props.onTypeSelected(p)}
                     />
                 ))
             }