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