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:
parent
9226e93f17
commit
393b961a6b
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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}`)
|
||||
}
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -117,7 +117,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.UserProperty {
|
||||
.Person {
|
||||
.react-select__value-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
@ -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>
|
||||
`;
|
@ -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
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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
|
@ -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
|
@ -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)
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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' : ''}`
|
||||
}
|
@ -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
|
||||
|
@ -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 () => {
|
||||
|
@ -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())
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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}>
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>,
|
||||
),
|
||||
|
@ -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={() => {
|
||||
|
@ -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>,
|
||||
),
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)'
|
||||
|
53
webapp/src/properties/baseTextEditor.tsx
Normal file
53
webapp/src/properties/baseTextEditor.tsx
Normal 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
|
24
webapp/src/properties/checkbox/checkbox.tsx
Normal file
24
webapp/src/properties/checkbox/checkbox.tsx
Normal 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
|
14
webapp/src/properties/checkbox/property.tsx
Normal file
14
webapp/src/properties/checkbox/property.tsx
Normal 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
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
)
|
||||
|
19
webapp/src/properties/createdBy/createdBy.tsx
Normal file
19
webapp/src/properties/createdBy/createdBy.tsx
Normal 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
|
13
webapp/src/properties/createdBy/property.tsx
Normal file
13
webapp/src/properties/createdBy/property.tsx
Normal 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'})
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
.CreatedAt {
|
||||
.CreatedTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
21
webapp/src/properties/createdTime/createdTime.tsx
Normal file
21
webapp/src/properties/createdTime/createdTime.tsx
Normal 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
|
24
webapp/src/properties/createdTime/property.tsx
Normal file
24
webapp/src/properties/createdTime/property.tsx
Normal 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)
|
||||
}
|
@ -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"
|
@ -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}))
|
||||
})
|
||||
})
|
@ -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
|
76
webapp/src/properties/date/property.tsx
Normal file
76
webapp/src/properties/date/property.tsx
Normal 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
|
||||
}
|
||||
}
|
20
webapp/src/properties/email/email.tsx
Normal file
20
webapp/src/properties/email/email.tsx
Normal 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
|
14
webapp/src/properties/email/property.tsx
Normal file
14
webapp/src/properties/email/property.tsx
Normal 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
|
||||
}
|
61
webapp/src/properties/index.tsx
Normal file
61
webapp/src/properties/index.tsx
Normal 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
|
@ -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"
|
@ -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)
|
||||
})
|
||||
})
|
91
webapp/src/properties/multiselect/multiselect.tsx
Normal file
91
webapp/src/properties/multiselect/multiselect.tsx
Normal 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
|
46
webapp/src/properties/multiselect/property.tsx
Normal file
46
webapp/src/properties/multiselect/property.tsx
Normal 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
|
||||
}
|
||||
}
|
17
webapp/src/properties/number/number.tsx
Normal file
17
webapp/src/properties/number/number.tsx
Normal 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
|
20
webapp/src/properties/number/property.tsx
Normal file
20
webapp/src/properties/number/property.tsx
Normal 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() : '')
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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(() => {
|
@ -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
|
12
webapp/src/properties/person/property.tsx
Normal file
12
webapp/src/properties/person/property.tsx
Normal 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'})
|
||||
}
|
17
webapp/src/properties/phone/phone.tsx
Normal file
17
webapp/src/properties/phone/phone.tsx
Normal 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
|
14
webapp/src/properties/phone/property.tsx
Normal file
14
webapp/src/properties/phone/property.tsx
Normal 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
|
||||
}
|
@ -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"
|
35
webapp/src/properties/select/property.tsx
Normal file
35
webapp/src/properties/select/property.tsx
Normal 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)
|
||||
}
|
||||
}
|
@ -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')
|
||||
})
|
||||
})
|
75
webapp/src/properties/select/select.tsx
Normal file
75
webapp/src/properties/select/select.tsx
Normal 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)
|
14
webapp/src/properties/text/property.tsx
Normal file
14
webapp/src/properties/text/property.tsx
Normal 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
|
||||
}
|
19
webapp/src/properties/text/text.tsx
Normal file
19
webapp/src/properties/text/text.tsx
Normal 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
|
71
webapp/src/properties/types.tsx
Normal file
71
webapp/src/properties/types.tsx
Normal 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
|
||||
}
|
11
webapp/src/properties/unknown/property.tsx
Normal file
11
webapp/src/properties/unknown/property.tsx
Normal 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'})
|
||||
}
|
@ -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>
|
||||
`;
|
13
webapp/src/properties/updatedBy/property.tsx
Normal file
13
webapp/src/properties/updatedBy/property.tsx
Normal 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'})
|
||||
}
|
@ -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>
|
||||
)
|
@ -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
|
||||
/>
|
||||
)
|
||||
}
|
@ -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>
|
||||
`;
|
25
webapp/src/properties/updatedTime/property.tsx
Normal file
25
webapp/src/properties/updatedTime/property.tsx
Normal 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)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
.LastModifiedAt {
|
||||
.UpdatedTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
@ -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>,
|
||||
)
|
||||
|
@ -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
|
@ -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"
|
14
webapp/src/properties/url/property.tsx
Normal file
14
webapp/src/properties/url/property.tsx
Normal 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
|
||||
}
|
@ -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'})
|
121
webapp/src/properties/url/url.tsx
Normal file
121
webapp/src/properties/url/url.tsx
Normal 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
|
@ -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}
|
||||
/>,
|
||||
|
@ -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)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user