1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-26 20:53:55 +02:00

Refactor of prorperties (#3356)

* Initial commit for refactoring properties

* More work on isolating properties

* Some other fixes in tests!

* Handle gracefully the unknown properties

* Moving properties outside components folder

* Finishing changes to move the properties out of components

* Moving more things to the property logic

* Some improvements on properties

* Cleaner class based approach for property types

* Removing accidentally added people prop

* A bit of simplification

* Fixing some tests

* Fixing more tests

* Fixing more tests

* All tests working

* Fixing eslint errors

* Adding the filtering logic for text and boolean properties

* Adding support for searching by title

* Fixing some tests and adding others

* Fixing tests

* Removing TODO

* Addressing PR review comments

* Fixing filterValue test after merge

* Removing accidentailly included typo

* Fixing typo introduced
This commit is contained in:
Jesús Espino 2022-08-16 18:00:43 +02:00 committed by GitHub
parent 9226e93f17
commit 393b961a6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 2678 additions and 1433 deletions

View File

@ -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,

View File

@ -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

View File

@ -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}`)
}

View File

@ -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>
`;

View File

@ -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

View File

@ -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,
}

View File

@ -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,

View File

@ -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"

View File

@ -117,7 +117,7 @@
}
}
.UserProperty {
.Person {
.react-select__value-container {
padding: 0;
}

View File

@ -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()
})
})

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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>
`;

View File

@ -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

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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' : ''}`
}

View File

@ -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

View File

@ -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 () => {

View File

@ -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())

View File

@ -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>

View File

@ -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) {

View File

@ -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"

View File

@ -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>
`;

View File

@ -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}>

View File

@ -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
}

View File

@ -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>,
),

View File

@ -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={() => {

View File

@ -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>,
),

View File

@ -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}

View File

@ -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 (

View File

@ -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}

View File

@ -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 (

View File

@ -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)
})

View File

@ -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
}

View File

@ -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)'

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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>
`;

View File

@ -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>
)

View File

@ -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

View File

@ -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'})
}

View File

@ -1,4 +1,4 @@
.CreatedAt {
.CreatedTime {
display: flex;
align-items: center;
}

View File

@ -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

View File

@ -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)
}

View File

@ -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"

View File

@ -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}))
})
})

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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"

View File

@ -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)
})
})

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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() : '')
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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(() => {

View File

@ -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

View File

@ -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'})
}

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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)
}
}

View File

@ -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')
})
})

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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'})
}

View File

@ -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>
`;

View File

@ -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'})
}

View File

@ -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>
)

View File

@ -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
/>
)
}

View File

@ -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>
`;

View File

@ -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)
}

View File

@ -1,4 +1,4 @@
.LastModifiedAt {
.UpdatedTime {
display: flex;
align-items: center;
}

View File

@ -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>,
)

View File

@ -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

View File

@ -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"

View File

@ -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
}

View File

@ -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'})

View File

@ -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

View File

@ -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}
/>,

View File

@ -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)}
/>
))
}