Implement Multi Select Values (#415)

This commit is contained in:
Hossein 2021-06-03 16:48:16 -04:00 committed by GitHub
parent c5771f7c9f
commit e7126b1835
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 257 additions and 52 deletions

View file

@ -7,7 +7,7 @@ import {IBlock, MutableBlock} from './block'
interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string>>
readonly properties: Readonly<Record<string, string | string[]>>
readonly contentOrder: readonly string[]
duplicate(): MutableCard
@ -28,10 +28,10 @@ class MutableCard extends MutableBlock implements Card {
this.fields.isTemplate = value
}
get properties(): Record<string, string> {
return this.fields.properties as Record<string, string>
get properties(): Record<string, string | string[]> {
return this.fields.properties as Record<string, string | string[]>
}
set properties(value: Record<string, string>) {
set properties(value: Record<string, string | string[]>) {
this.fields.properties = value
}

View file

@ -23,6 +23,7 @@
width: 100%;
.MenuWrapper {
position: relative;
align-self: center;
}
}

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {IPropertyOption, IPropertyTemplate} from '../../blocks/board'
import Label from '../../widgets/label'
import ValueSelector from '../../widgets/valueSelector'
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 values = Array.isArray(propertyValue) ?
propertyValue.map((v) => propertyTemplate.options.find((o) => o!.id === v)).filter((v): v is IPropertyOption => Boolean(v)) :
[]
if (!isEditable) {
return (
<div
className='octo-property-value'
tabIndex={0}
>
{values.map((v) => (
<Label
key={v.id}
color={v ? v.color : 'empty'}
>
{v.value}
</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)}
/>
)
}
export default MultiSelectProperty

View file

@ -12,11 +12,13 @@ import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import Editable from '../widgets/editable'
import ValueSelector from '../widgets/valueSelector'
import Label from '../widgets/label'
import EditableDayPicker from '../widgets/editableDayPicker'
import Switch from '../widgets/switch'
import MultiSelectProperty from './properties/multiSelect'
import URLProperty from './properties/link/link'
type Props = {
@ -60,6 +62,33 @@ const PropertyValueElement = (props:Props): JSX.Element => {
}
}
if (propertyTemplate.type === 'multiSelect') {
return (
<MultiSelectProperty
isEditable={!readOnly && Boolean(boardTree)}
emptyValue={emptyDisplayValue}
propertyTemplate={propertyTemplate}
propertyValue={propertyValue}
onChange={(newValue) => mutator.changePropertyValue(card, propertyTemplate.id, newValue)}
onChangeColor={(option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(boardTree!.board, propertyTemplate, option, colorId)}
onDeleteOption={(option: IPropertyOption) => mutator.deletePropertyOption(boardTree!, propertyTemplate, option)}
onCreate={
async (newValue, currentValues) => {
const option: IPropertyOption = {
id: Utils.createGuid(),
value: newValue,
color: 'propColorDefault',
}
currentValues.push(option)
await mutator.insertPropertyOption(boardTree!, propertyTemplate, option, 'add property option')
mutator.changePropertyValue(card, propertyTemplate.id, currentValues.map((v) => v.id))
}
}
onDeleteValue={(valueToDelete, currentValues) => mutator.changePropertyValue(card, propertyTemplate.id, currentValues.filter((currentValue) => currentValue.id !== valueToDelete.id).map((currentValue) => currentValue.id))}
/>
)
}
if (propertyTemplate.type === 'select') {
let propertyColorCssClassName = ''
const cardPropertyValue = propertyTemplate.options.find((o) => o.id === propertyValue)
@ -107,7 +136,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
} else if (propertyTemplate.type === 'url') {
return (
<URLProperty
value={value}
value={value as string}
onChange={setValue}
onSave={() => mutator.changePropertyValue(card, propertyTemplate.id, value)}
onCancel={() => setValue(propertyValue)}
@ -123,7 +152,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
return (
<EditableDayPicker
className='octo-propertyvalue'
value={value}
value={value as string}
onChange={(newValue) => mutator.changePropertyValue(card, propertyTemplate.id, newValue)}
/>
)
@ -152,7 +181,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
<Editable
className='octo-propertyvalue'
placeholderText='Empty'
value={value}
value={value as string}
onChange={setValue}
onSave={() => mutator.changePropertyValue(card, propertyTemplate.id, value)}
onCancel={() => setValue(propertyValue)}

View file

@ -9,7 +9,7 @@
padding: 8px;
min-height: 32px;
font-size: 14px;
line-height: 20px;
line-height: 30px;
position: relative;
text-overflow: ellipsis;

View file

@ -82,7 +82,7 @@ const Table = (props: Props) => {
return
}
displayValue = OctoUtils.propertyDisplayValue(card, card.properties[columnID], template!, props.intl) || ''
displayValue = (OctoUtils.propertyDisplayValue(card, card.properties[columnID], template!, props.intl) || '') as string
if (template.type === 'select') {
displayValue = displayValue.toUpperCase()
}

View file

@ -67,7 +67,7 @@ class CsvExporter {
row.push(`"${this.encodeText(card.title)}"`)
visibleProperties.forEach((template) => {
const propertyValue = card.properties[template.id]
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template, intl) || ''
const displayValue = (OctoUtils.propertyDisplayValue(card, propertyValue, template, intl) || '') as string
if (template.type === 'number') {
const numericValue = propertyValue ? Number(propertyValue).toString() : ''
row.push(numericValue)

View file

@ -372,7 +372,7 @@ class Mutator {
await this.updateBlock(newBoard, board, 'change option color')
}
async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') {
async changePropertyValue(card: Card, propertyId: string, value?: string | string[], description = 'change property') {
const newCard = new MutableCard(card)
if (value) {
newCard.properties[propertyId] = value
@ -383,6 +383,10 @@ class Mutator {
}
async changePropertyType(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, type: PropertyType) {
if (propertyTemplate.type === type) {
return
}
const {board} = boardTree
const newBoard = new MutableBoard(board)
@ -392,27 +396,40 @@ class Mutator {
const oldBlocks: IBlock[] = [board]
const newBlocks: IBlock[] = [newBoard]
if (propertyTemplate.type === 'select') {
// Map select to their values
if (propertyTemplate.type === 'select' || propertyTemplate.type === 'multiSelect') { // If the old type was either select or multiselect
const isNewTypeSelectOrMulti = type === 'select' || type === 'multiSelect'
for (const card of boardTree.allCards) {
const oldValue = card.properties[propertyTemplate.id]
const oldValue = Array.isArray(card.properties[propertyTemplate.id]) ?
(card.properties[propertyTemplate.id].length > 0 && card.properties[propertyTemplate.id][0]) :
card.properties[propertyTemplate.id]
if (oldValue) {
const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value
const newValue = isNewTypeSelectOrMulti ?
propertyTemplate.options.find((o) => o.id === oldValue)?.id :
propertyTemplate.options.find((o) => o.id === oldValue)?.value
const newCard = new MutableCard(card)
if (newValue) {
newCard.properties[propertyTemplate.id] = newValue
newCard.properties[propertyTemplate.id] = type === 'multiSelect' ? [newValue] : newValue
} else {
// This was an invalid select option, so delete it
delete newCard.properties[propertyTemplate.id]
}
newBlocks.push(newCard)
oldBlocks.push(card)
}
if (isNewTypeSelectOrMulti) {
newTemplate.options = propertyTemplate.options
}
}
} else if (type === 'select') {
} else if (type === 'select' || type === 'multiSelect') { // if the new type is either select or multiselect
// Map values to new template option IDs
for (const card of boardTree.allCards) {
const oldValue = card.properties[propertyTemplate.id]
const oldValue = card.properties[propertyTemplate.id] as string
if (oldValue) {
let option = newTemplate.options.find((o) => o.value === oldValue)
if (!option) {
@ -425,7 +442,7 @@ class Mutator {
}
const newCard = new MutableCard(card)
newCard.properties[propertyTemplate.id] = option.id
newCard.properties[propertyTemplate.id] = type === 'multiSelect' ? [option.id] : option.id
newBlocks.push(newCard)
oldBlocks.push(card)

View file

@ -16,8 +16,8 @@ import {FilterCondition} from './blocks/filterClause'
import {Utils} from './utils'
class OctoUtils {
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate, intl: IntlShape): string | undefined {
let displayValue: string | undefined
static propertyDisplayValue(block: IBlock, 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
@ -40,7 +40,7 @@ class OctoUtils {
}
case 'date': {
if (propertyValue) {
displayValue = Utils.displayDate(new Date(parseInt(propertyValue, 10)), intl)
displayValue = Utils.displayDate(new Date(parseInt(propertyValue as string, 10)), intl)
}
break
}

View file

@ -216,7 +216,9 @@ class MutableBoardTree implements BoardTree {
if (option?.value.toLowerCase().includes(searchText)) {
return true
}
} else if (propertyValue.toLowerCase().includes(searchText)) {
// TODO: Add search capability for multi-select values BIG BOYY
} else if ((propertyValue as string).toLowerCase().includes(searchText)) {
return true
}
}
@ -425,7 +427,7 @@ class MutableBoardTree implements BoardTree {
return this.titleOrCreatedOrder(a, b)
}
result = aValue.localeCompare(bValue)
result = (aValue as string).localeCompare(bValue as string)
}
if (result === 0) {

View file

@ -157,6 +157,21 @@ exports[`widgets/PropertyMenu should match snapshot 1`] = `
class="noicon"
/>
</div>
<div
class="MenuOption TextOption menu-option"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Multi Select
</div>
<div
class="noicon"
/>
</div>
<div
class="MenuOption TextOption menu-option"
>

View file

@ -20,4 +20,8 @@
height: 32px;
}
}
&.margin-left {
margin-left: 5px;
}
}

View file

@ -9,6 +9,7 @@ type Props = {
title?: string
icon?: React.ReactNode
className?: string
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void
}
function IconButton(props: Props): JSX.Element {
@ -19,6 +20,7 @@ function IconButton(props: Props): JSX.Element {
return (
<div
onClick={props.onClick}
onMouseDown={props.onMouseDown}
className={className}
title={props.title}
>

View file

@ -9,6 +9,8 @@
text-transform: uppercase;
font-weight: 600;
font-size: 13px;
margin-right: 5px;
margin-bottom: 5px;
input {
line-height: 20px;

View file

@ -8,13 +8,14 @@ type Props = {
color?: string
title?: string
children: React.ReactNode
classNames?: string
}
// Switch is an on-off style switch / checkbox
function Label(props: Props): JSX.Element {
return (
<span
className={`Label ${props.color || 'empty'}`}
className={`Label ${props.color || 'empty'} ${props.classNames ? props.classNames : ''}`}
title={props.title}
>
{props.children}

View file

@ -119,6 +119,11 @@ const PropertyMenu = React.memo((props: Props) => {
name={typeDisplayName(intl, 'select')}
onClick={() => props.onTypeChanged('select')}
/>
<Menu.Text
id='multiSelect'
name={typeDisplayName(intl, 'multiSelect')}
onClick={() => props.onTypeChanged('multiSelect')}
/>
<Menu.Text
id='date'
name={typeDisplayName(intl, 'date')}

View file

@ -13,18 +13,37 @@
}
.Label {
display: inline-block;
display: flex;
text-overflow: ellipsis;
overflow: hidden;
border-radius: var(--default-rad);
max-width: 100%;
}
.Label-no-padding {
padding-top: 0;
padding-bottom: 0;
}
.Label-single-select {
margin-bottom: 0px;
}
.Label-text {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
max-width: 250px;
}
.value-menu-option {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
.label-container {
flex-grow: 1;
display: flex;
}
.MenuWrapper {
display: flex;
@ -38,3 +57,19 @@
}
}
}
.label-container > .Label {
max-width: 600px;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.octo-property-value > .Label {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}

View file

@ -1,6 +1,6 @@
// 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 {useIntl} from 'react-intl'
import {ActionMeta, ValueType, FormatOptionLabelMeta} from 'react-select'
import CreatableSelect from 'react-select/creatable'
@ -14,32 +14,52 @@ import MenuWrapper from './menuWrapper'
import IconButton from './buttons/iconButton'
import OptionsIcon from './icons/options'
import DeleteIcon from './icons/delete'
import CloseIcon from './icons/close'
import Label from './label'
import './valueSelector.scss'
type Props = {
options: IPropertyOption[]
value?: IPropertyOption
value?: IPropertyOption | IPropertyOption[]
emptyValue: string
onCreate: (value: string) => void
onChange: (value: string) => void
onChange: (value: string | string[]) => void
onChangeColor: (option: IPropertyOption, color: string) => void
onDeleteOption: (option: IPropertyOption) => void
isMulti?: boolean
onDeleteValue?: (value: IPropertyOption) => void
}
type LabelProps = {
option: IPropertyOption
meta: FormatOptionLabelMeta<IPropertyOption, false>
meta: FormatOptionLabelMeta<IPropertyOption, true | false>
onChangeColor: (option: IPropertyOption, color: string) => void
onDeleteOption: (option: IPropertyOption) => void
onDeleteValue?: (value: IPropertyOption) => void
}
const ValueSelectorLabel = React.memo((props: LabelProps): JSX.Element => {
const {option, meta} = props
const {option, onDeleteValue, meta} = props
const intl = useIntl()
if (meta.context === 'value') {
return <Label color={option.color}>{option.value}</Label>
return (
<Label
color={option.color}
classNames={`${onDeleteValue ? 'Label-no-padding' : 'Label-single-select'}`}
>
<span className='Label-text'>{option.value}</span>
{onDeleteValue &&
<IconButton
onClick={() => onDeleteValue(option)}
onMouseDown={(e) => e.stopPropagation()}
icon={<CloseIcon/>}
title='Close'
className='margin-left'
/>
}
</Label>
)
}
return (
<div className='value-menu-option'>
@ -71,22 +91,9 @@ const ValueSelectorLabel = React.memo((props: LabelProps): JSX.Element => {
})
function ValueSelector(props: Props): JSX.Element {
const [activated, setActivated] = useState(false)
if (!activated) {
return (
<div
className='ValueSelector'
onClick={() => setActivated(true)}
>
<Label color={props.value ? props.value.color : 'empty'}>
{props.value ? props.value.value : props.emptyValue}
</Label>
</div>
)
}
return (
<CreatableSelect
isMulti={props.isMulti}
isClearable={true}
styles={{
indicatorsContainer: (provided: CSSObject): CSSObject => ({
@ -102,12 +109,12 @@ function ValueSelector(props: Props): JSX.Element {
...provided,
background: state.isFocused ? 'rgba(var(--main-fg), 0.1)' : 'rgb(var(--main-bg))',
color: state.isFocused ? 'rgb(var(--main-fg))' : 'rgb(var(--main-fg))',
padding: '2px 8px',
padding: '8px',
}),
control: (): CSSObject => ({
border: 0,
width: '100%',
margin: '4px 0 0 0',
margin: '0',
}),
valueContainer: (provided: CSSObject): CSSObject => ({
...provided,
@ -131,22 +138,42 @@ function ValueSelector(props: Props): JSX.Element {
...provided,
overflowY: 'unset',
}),
multiValue: (provided: CSSObject): CSSObject => ({
...provided,
margin: 0,
padding: 0,
backgroundColor: 'transparent',
}),
multiValueLabel: (provided: CSSObject): CSSObject => ({
...provided,
display: 'flex',
paddingLeft: 0,
padding: 0,
}),
multiValueRemove: (): CSSObject => ({
display: 'none',
}),
}}
formatOptionLabel={(option: IPropertyOption, meta: FormatOptionLabelMeta<IPropertyOption, false>) => (
formatOptionLabel={(option: IPropertyOption, meta: FormatOptionLabelMeta<IPropertyOption, true | false>) => (
<ValueSelectorLabel
option={option}
meta={meta}
onChangeColor={props.onChangeColor}
onDeleteOption={props.onDeleteOption}
onDeleteValue={props.onDeleteValue}
/>
)}
className='ValueSelector'
options={props.options}
getOptionLabel={(o: IPropertyOption) => o.value}
getOptionValue={(o: IPropertyOption) => o.id}
onChange={(value: ValueType<IPropertyOption, false>, action: ActionMeta<IPropertyOption>): void => {
onChange={(value: ValueType<IPropertyOption, true | false>, action: ActionMeta<IPropertyOption>): void => {
if (action.action === 'select-option') {
props.onChange((value as IPropertyOption).id)
if (Array.isArray(value)) {
props.onChange((value as IPropertyOption[]).map((option) => option.id))
} else {
props.onChange((value as IPropertyOption).id)
}
} else if (action.action === 'clear') {
props.onChange('')
}
@ -156,7 +183,8 @@ function ValueSelector(props: Props): JSX.Element {
value={props.value}
closeMenuOnSelect={true}
placeholder={props.emptyValue}
defaultMenuIsOpen={true}
hideSelectedOptions={false}
defaultMenuIsOpen={false}
/>
)
}