Commit ccb2837d authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch '189-fix-milestone-selector' into 'master'

fix milestone selector

See merge request inject/frontend!166
parents 21490052 34afe716
Loading
Loading
Loading
Loading
+0 −1
Original line number Diff line number Diff line
@@ -74,7 +74,6 @@ const EmailContactSelector: FC<EmailContactSelectorProps> = ({
      onItemSelect={onItemSelect}
      onRemove={onRemove}
      popoverProps={{
        matchTargetWidth: true,
        minimal: true,
      }}
      resetOnSelect
+1 −8
Original line number Diff line number Diff line
@@ -56,14 +56,7 @@ const InstructorEmailForm: FC<EmailFormProps> = ({
    emailThread,
    threadId,
    teamId,
    submitFunction({
      threadId,
      senderAddress,
      content,
      fileName,
      activateMilestone,
      deactivateMilestone,
    }) {
    submitFunction: (threadId: string) => {
      instructorMutate({
        variables: {
          threadId,
+4 −5
Original line number Diff line number Diff line
@@ -28,7 +28,6 @@ const TraineeEmailForm: FC<EmailFormProps> = ({
    setContent,
    discard,
    storeDraft,
    senderAddress,
    selectedContacts,
    setSelectedContacts,
    subject,
@@ -44,15 +43,15 @@ const TraineeEmailForm: FC<EmailFormProps> = ({
    file,
    fileName,
    subject: subject || '',
    senderAddress,
    senderAddress: teamAddress!,
    selectedContacts,
    threadId,
    teamId,
    submitFunction({ threadId, senderAddress, content, fileName }) {
    submitFunction: threadId => {
      traineeMutate({
        variables: {
          threadId,
          senderAddress,
          senderAddress: teamAddress!,
          content,
          fileName,
        },
@@ -98,7 +97,7 @@ const TraineeEmailForm: FC<EmailFormProps> = ({
              subject,
              setSubject,
            })}
        senderAddress={senderAddress}
        senderAddress={teamAddress!}
      />
      <Divider style={{ margin: '0.5rem 0' }} />

+2 −20
Original line number Diff line number Diff line
@@ -13,14 +13,7 @@ type ThreadSubmissionProps = {
  subject: string
  emailThread?: EmailThread
  threadId?: string
  submitFunction: ({
    threadId,
    senderAddress,
    content,
    fileName,
    activateMilestone,
    deactivateMilestone,
  }: SendEmailInput) => void
  submitFunction: (threadId: string) => void
} & Omit<SendEmailInput, 'threadId'>

const useThreadSubmission = ({
@@ -33,8 +26,6 @@ const useThreadSubmission = ({
  selectedContacts,
  subject,
  emailThread,
  activateMilestone,
  deactivateMilestone,
  submitFunction,
}: ThreadSubmissionProps) => {
  const [createThread] = useCreateThread()
@@ -102,19 +93,10 @@ const useThreadSubmission = ({
        })
    }

    return submitFunction({
      threadId: threadIdPtr as string,
      senderAddress,
      content,
      fileName,
      activateMilestone,
      deactivateMilestone,
    })
    return submitFunction(emailThread?.id || threadIdPtr!)
  }, [
    activateMilestone,
    content,
    createThread,
    deactivateMilestone,
    emailThread,
    file,
    fileName,
+86 −156
Original line number Diff line number Diff line
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { ItemRenderer, ItemRendererProps } from '@blueprintjs/select'
import { MultiSelect } from '@blueprintjs/select'
import { MenuItem } from '@blueprintjs/core'
import type { SetStateAction } from 'react'
import type React from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import notEmpty from '@inject/shared/utils/notEmpty'
import { useGetTeamMilestones } from '@inject/graphql/queries/GetTeamMilestones.generated'
import { MilestoneState } from '@inject/graphql/fragments/MilestoneState.generated'

// TODO: refactor this into an object, there is an O(n) search in the code, it can be "O(1)"
export interface MilestoneOption {
  name: string
  milestoneId: string
  selected?: boolean
  not: boolean
  milestoneState: MilestoneState
  selected: boolean
}

const MilestoneItemRenderer: ItemRenderer<MilestoneOption> = (
  milestone,
const getMilestoneOptionText = (milestoneOption: MilestoneOption) =>
  milestoneOption.milestoneState.reached
    ? `not ${milestoneOption.milestoneState.milestone.name}`
    : milestoneOption.milestoneState.milestone.name

const MilestoneOptionRenderer: ItemRenderer<MilestoneOption> = (
  milestoneOption,
  { handleClick, handleFocus, modifiers, ref }: ItemRendererProps
) => {
  if (!modifiers.matchesPredicate) {
@@ -24,9 +28,9 @@ const MilestoneItemRenderer: ItemRenderer<MilestoneOption> = (
  }
  return (
    <MenuItem
      key={milestone?.name}
      text={milestone?.name}
      selected={milestone?.selected}
      key={milestoneOption.milestoneState.id}
      text={getMilestoneOptionText(milestoneOption)}
      selected={milestoneOption.selected}
      roleStructure='listoption'
      shouldDismissPopover={false}
      onClick={handleClick}
@@ -40,8 +44,6 @@ const MilestoneItemRenderer: ItemRenderer<MilestoneOption> = (

interface MilestoneSelectorProps {
  teamId: string
  /** Initialization string, contains milestone strings separated with " and " as specified in the docs
   * when string is changed with setState, selector is initialized again */
  activateMilestone: string
  deactivateMilestone: string
  setActivateMilestone: React.Dispatch<SetStateAction<string>>
@@ -62,171 +64,101 @@ const MilestoneSelector = ({
    },
  })

  const [milestones, setMilestonesList] = useState<MilestoneOption[]>(
    (data?.teamMilestones || []).filter(notEmpty).flatMap(
      // TODO remove this condition object creation, if invisible milestones causes problems
      ({ milestone, reached }) =>
        !reached
          ? {
              name: milestone.name || '',
              milestoneId: milestone.name || '',
              not: false,
            }
          : {
              name: `not ${milestone.name || ''}`,
              milestoneId: milestone.name || '',
              not: true,
            }
    )
  const [milestoneOptions, setMilestoneOptions] = useState<MilestoneOption[]>(
    (data?.teamMilestones || [])
      .filter(notEmpty)
      .map(milestoneState => ({ milestoneState, selected: false }))
  )

  const [selectedMilestones, setSelectedMilestones] = useState<
    MilestoneOption[]
  >(() => {
    // this is duplicate compared to prev version, it is necessary to prevent state changes, when initializing
    // instead of using handleItemChange
    const preselected: MilestoneOption[] = []
    const milestoneloop = (set: boolean) => (milestone: string) => {
      const milestoneOption = milestones.find(m => m.name === milestone)
      if (milestoneOption) {
        setMilestonesList(prevState => {
          milestoneOption.selected = set
          return [...prevState]
        })
        preselected.push(milestoneOption)
      }
    }
    activateMilestone.split(',').forEach(milestoneloop(true))
    deactivateMilestone.split(',').forEach(milestoneloop(true))
    return preselected
  })

  useEffect(() => {
    setActivateMilestone(
      milestones
        .filter(x => x.selected)
        .filter(x => !x.not)
        .map(milestone => milestone.name)
        .join(',')
    )
    setDeactivateMilestone(
      milestones
        .filter(x => x.selected)
        .filter(x => x.not)
        .map(milestone => milestone.name)
        .map(x => x.slice(4))
        .join(',')
  // TODO: update when loading a template
  // const setSelectedMilestones = () => {
  //   const milestoneNames = [
  //     ...activateMilestone.split(','),
  //     ...deactivateMilestone.split(','),
  //   ]

  //   setMilestoneOptions(prev =>
  //     prev.map(milestoneOption => ({
  //       ...milestoneOption,
  //       selected: milestoneNames.includes(
  //         milestoneOption.milestoneState.milestone.name
  //       ),
  //     }))
  //   )
  // }
  // useEffect(
  //   () => setSelectedMilestones(),
  //   [activateMilestone, deactivateMilestone]
  // )

  const milestoneOptionsToString = (reached: boolean) =>
    milestoneOptions
      .filter(milestoneOption => milestoneOption.selected)
      .filter(milestoneOption =>
        reached
          ? milestoneOption.milestoneState.reached
          : !milestoneOption.milestoneState.reached
      )
  }, [
    selectedMilestones,
    milestones,
    setActivateMilestone,
    setDeactivateMilestone,
  ])
      .map(milestoneOption => milestoneOption.milestoneState.milestone.name)
      .join(' ')

  const dropdownRef = useRef(null)
  useEffect(() => {
    setActivateMilestone(milestoneOptionsToString(false))
    setDeactivateMilestone(milestoneOptionsToString(true))
  }, [milestoneOptions])

  const handleItemChange = (
    milestone: MilestoneOption,
    selected: boolean | undefined
    milestoneOption: MilestoneOption,
    selected: boolean
  ) => {
    setMilestonesList(prevState => {
      prevState.forEach(prevMilestone => {
        if (prevMilestone.milestoneId === milestone.milestoneId) {
          prevMilestone.selected =
            selected === undefined ? undefined : !selected
        }
      })
      milestone.selected = selected
      return [...prevState]
    })
    const index = milestoneOptions.indexOf(milestoneOption)

    setSelectedMilestones(prevState => {
      if (selected !== undefined) {
        return [...prevState, milestone]
      }
      return [
        ...prevState.filter(
          selectedMilestone => selectedMilestone.name !== milestone.name
        ),
      ]
    })
  }

  useEffect(() => {
    // TODO: i don't what this code is, but I haven't tested what it will do if I remove it
    // TODO: test if behaviour is not broken, when email is based on a template
    const activatedNames = activateMilestone.split(',')
    const deactivatedNames = deactivateMilestone.split(',')

    selectedMilestones.forEach(milestone => {
      if (!activatedNames.includes(milestone.name)) {
        handleItemChange(milestone, undefined)
      }
      if (!deactivatedNames.includes(milestone.name)) {
        handleItemChange(milestone, undefined)
      }
    })
    const arr = [activatedNames, deactivatedNames]
    arr.forEach(x =>
      x.forEach(milestoneName => {
        const milestone = milestones.find(m => m.name === milestoneName)
        if (milestone && !milestone?.selected) {
          handleItemChange(milestone, true)
        }
      })
    )
  }, [])

  const selectionFilter = useCallback(
    (query: string, milestone: MilestoneOption) => {
      if (milestone.selected !== undefined) {
        return false
    setMilestoneOptions(prev => [
      ...prev.slice(0, index),
      { ...prev[index], selected },
      ...prev.slice(index + 1),
    ])
  }

      return milestone.name.toLowerCase().includes(query.toLowerCase())
    },
    []
  )

  const disabled = (milestone: MilestoneOption) =>
    milestone.selected !== undefined

  return (
    <MultiSelect<MilestoneOption>
      placeholder='Reach milestones...'
      items={milestones}
      itemRenderer={MilestoneItemRenderer}
      onItemSelect={milestone => {
        handleItemChange(milestone, true)
      placeholder='Reach milestones'
      items={milestoneOptions}
      itemRenderer={MilestoneOptionRenderer}
      onItemSelect={milestoneOption => {
        handleItemChange(milestoneOption, true)
      }}
      tagInputProps={{
        onRemove: (tag, index) => {
        onRemove: tag => {
          const name = tag?.valueOf()
          if (selectedMilestones[index].name === name) {
            handleItemChange(selectedMilestones[index], undefined)
          }
          const index = milestoneOptions.indexOf(
            milestoneOptions.find(
              milestoneOption =>
                milestoneOption.milestoneState.milestone.name === name
            )!
          )
          // TODO: not ideal, searches for the index twice
          handleItemChange(milestoneOptions[index], false)
        },
        tagProps: {
          minimal: true,
        },
      }}
      tagRenderer={milestone =>
        milestone.selected ? milestone.name : `not ${milestone.name}`
      tagRenderer={getMilestoneOptionText}
      selectedItems={milestoneOptions.filter(
        milestoneOption => milestoneOption.selected
      )}
      itemPredicate={(query, milestoneOption) =>
        milestoneOption.milestoneState.milestone.name
          .toLowerCase()
          .includes(query.toLowerCase())
      }
      selectedItems={selectedMilestones}
      itemPredicate={selectionFilter}
      resetOnSelect
      onClear={() => {
        setSelectedMilestones([])
        setMilestonesList(prevState => {
          prevState.forEach(milestone => {
            milestone.selected = undefined
          })
          return [...milestones]
        })
        setMilestoneOptions(prev =>
          prev.map(milestoneOption => ({ ...milestoneOption, selected: false }))
        )
      }}
      popoverRef={dropdownRef}
      menuProps={{
        'aria-label': 'milestones',
      }}
@@ -237,8 +169,6 @@ const MilestoneSelector = ({
          roleStructure='listoption'
        />
      }
      openOnKeyDown
      itemDisabled={disabled}
      popoverProps={{
        minimal: true,
      }}