diff --git a/components/pages/Goals/modals/GoalCreationModal/modals/GoalCreationCloseSubmitModal.tsx b/components/pages/Goals/modals/GoalCreationModal/modals/GoalCreationCloseSubmitModal.tsx index 3120d4e2..c46b84ec 100644 --- a/components/pages/Goals/modals/GoalCreationModal/modals/GoalCreationCloseSubmitModal.tsx +++ b/components/pages/Goals/modals/GoalCreationModal/modals/GoalCreationCloseSubmitModal.tsx @@ -21,7 +21,7 @@ export const GoalCreationCloseSubmitModal = observer( function GoalCreationCloseSubmitModal({ onClose, onSubmit }: GoalCreationCloseSubmitModalProps) { const initialRef = useRef(null); - useHotkeysHandler({ STAY: ['meta+enter'] }, { STAY: onClose }); + useHotkeysHandler({ STAY: ['meta+enter', 'ctrl+enter'] }, { STAY: onClose }); return ( ; keymap = { - SAVE: ['meta+enter'] + SAVE: ['meta+enter', 'ctrl+enter'] }; constructor() { diff --git a/components/pages/Spaces/modals/SpaceCreationModal/view.tsx b/components/pages/Spaces/modals/SpaceCreationModal/view.tsx index 6e13bc17..49128718 100644 --- a/components/pages/Spaces/modals/SpaceCreationModal/view.tsx +++ b/components/pages/Spaces/modals/SpaceCreationModal/view.tsx @@ -28,9 +28,10 @@ import { TextAreaLengthCounter } from '../../../../shared/TextAreaLengthCounter' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faAlignLeft, faTrashCan } from '@fortawesome/pro-light-svg-icons'; import { EmojiSelect } from "../../../../shared/EmojiSelect"; +import { isMac } from '../../../../../helpers/os'; const keyMap = { - CREATE: ['meta+enter', 'meta+s'], + CREATE: ['meta+enter', 'meta+s', 'ctrl+enter'], CANCEL: ['escape'], }; @@ -151,7 +152,7 @@ export const SpaceCreationModalView = observer(function SpaceCreationModal() { > Save - ⌘ + Enter + {`${isMac() ? '⌘' : 'Ctrl'} + Enter`} diff --git a/components/shared/GoalsSelection/view.tsx b/components/shared/GoalsSelection/view.tsx index 278fa173..7cef6362 100644 --- a/components/shared/GoalsSelection/view.tsx +++ b/components/shared/GoalsSelection/view.tsx @@ -13,6 +13,7 @@ import { GoalsSelectionProps, useGoalsSelectionStore } from './store'; import React, { useRef } from 'react'; import { LargePlusIcon } from '../Icons/LargePlusIcon'; import { GoalIcon } from '../GoalIcon'; +import { HeavyPlusIcon } from '../Icons/HeavyPlusIcon'; type GoalSelectionListItemProps = { id: string | null; @@ -112,6 +113,49 @@ export const GoalsSelectionView = observer(function GoalsSelectionView( checkboxContent={index < 9 ? index + 1 : null} /> ))} + {store.root.resources.goals.list?.length < 9 && + + props.setRefs(store.root.resources.goals.list.length + 1, el)} + isChecked={false} + size='xl' + position='relative' + width='100%' + icon={ + + + } + css={{ + '.chakra-checkbox__label': { + width: 'calc(100% - 2rem)', + }, + '.chakra-checkbox__control': { + borderRadius: '100%', + } + }} + > + + Create new goal + + + + } ) : ( diff --git a/components/shared/PrioritySelection/components/PriorityListItem.tsx b/components/shared/PrioritySelection/components/PriorityListItem.tsx new file mode 100644 index 00000000..e0d54026 --- /dev/null +++ b/components/shared/PrioritySelection/components/PriorityListItem.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { + chakra, + Checkbox, + ListItem, + forwardRef, +} from '@chakra-ui/react'; +import { usePrioritySelectionStore } from '../store'; +import { TaskPriority, TaskPriorityNames } from '../../TasksList/types'; +import { TaskPriorityIcon } from '../../Icons/TaskPriorityIcon'; + +type PriorityListItemProps = { + priority: TaskPriority; + checkboxContent?: React.ReactNode; +}; + +export const PriorityListItem = observer( + forwardRef(function PriorityListItem( + { priority, checkboxContent }: PriorityListItemProps, + ref + ) { + const store = usePrioritySelectionStore(); + + return ( + + store.handlePriorityCheck(priority)} + size='xl' + position='relative' + fontWeight='semibold' + fontSize='lg' + width='100%' + icon={checkboxContent ? <> : undefined} + css={{ + '.chakra-checkbox__label': { + width: 'calc(100% - 2rem)', + } + }} + > + {checkboxContent ? ( + + {checkboxContent} + + ) : null} + + + + + + {TaskPriorityNames[priority]} + + + + + + + ); + }) +); diff --git a/components/shared/PrioritySelection/index.tsx b/components/shared/PrioritySelection/index.tsx new file mode 100644 index 00000000..57ecf6da --- /dev/null +++ b/components/shared/PrioritySelection/index.tsx @@ -0,0 +1,13 @@ +import { observer } from 'mobx-react-lite'; +import { PrioritySelectionView } from './view'; +import { PrioritySelectionProps, PrioritySelectionStoreProvider } from './store'; + +export const PrioritySelection = observer(function GoalsSelection( + props: PrioritySelectionProps +) { + return ( + + + + ); +}); diff --git a/components/shared/PrioritySelection/store.ts b/components/shared/PrioritySelection/store.ts new file mode 100644 index 00000000..92ddd3c8 --- /dev/null +++ b/components/shared/PrioritySelection/store.ts @@ -0,0 +1,61 @@ +import { makeAutoObservable } from 'mobx'; +import { RootStore } from '../../../stores/RootStore'; +import { getProvider } from '../../../helpers/StoreProvider'; + +export type PrioritySelectionProps = { + callbacks?: { + onSelect?: (goalIds: string[]) => void; + }; + + setRefs?: (index: number, ref: HTMLElement) => void; + checked?: string[]; +}; + +export class PrioritySelectionStore { + constructor(public root: RootStore) { + makeAutoObservable(this); + } + + callbacks: PrioritySelectionProps['callbacks'] = {}; + + checkedPriority: Record = {}; + isFocused: boolean = false; + multiple: boolean = false; + + get checked() { + return Object.keys(this.checkedPriority); + } + + handlePriorityCheck = (key: string) => { + const priority = key === null ? null : key; + if (priority !== null) { + this.checkedPriority = { + [priority]: true, + }; + } else { + this.checkedPriority = {}; + } + + this.callbacks.onSelect?.(this.checked); + }; + + uncheckAll = () => { + this.checkedPriority = {}; + this.callbacks.onSelect?.(this.checked); + }; + + update = (props: PrioritySelectionProps) => { + this.callbacks = props.callbacks; + + if (props.checked) { + this.checkedPriority = { + [props.checked[0]]: true, + }; + } + }; +} + +export const { + StoreProvider: PrioritySelectionStoreProvider, + useStore: usePrioritySelectionStore, +} = getProvider(PrioritySelectionStore); diff --git a/components/shared/PrioritySelection/view.tsx b/components/shared/PrioritySelection/view.tsx new file mode 100644 index 00000000..2a76e5a4 --- /dev/null +++ b/components/shared/PrioritySelection/view.tsx @@ -0,0 +1,26 @@ +import React, { useRef } from 'react'; +import { observer } from 'mobx-react-lite'; +import { List } from '@chakra-ui/react'; +import { PrioritySelectionProps, usePrioritySelectionStore } from './store'; +import { PriorityListItem } from './components/PriorityListItem' +import { TaskPriorityArray } from '../TasksList/types'; + +export const PrioritySelectionView = observer(function SpaceSelectionView( + props: Partial +) { + const store = usePrioritySelectionStore(); + const ref = useRef(); + + return ( + + {TaskPriorityArray.map((item, index) => ( + props.setRefs(index + 1, el)} + key={item} + priority={item} + checkboxContent={index < 9 ? index + 1 : null} + /> + ))} + + ) +}); diff --git a/components/shared/TactTaskTag/index.tsx b/components/shared/TactTaskTag/index.tsx new file mode 100644 index 00000000..87484745 --- /dev/null +++ b/components/shared/TactTaskTag/index.tsx @@ -0,0 +1,87 @@ +import { Button, Tag, IconButton, ButtonProps } from "@chakra-ui/react"; +import { faXmark } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { forwardRef } from "react"; + +interface TactTaskTagProps { + buttonProps?: ButtonProps; + iconButtonProps?: ButtonProps; + tagProps?: ButtonProps; + showRemoveIcon?: boolean; + title: string; + selected?: boolean; +} + +const TactTaskTag = forwardRef(({ + buttonProps, + iconButtonProps, + tagProps, + title, + showRemoveIcon = false, + selected = false, +}, ref) => ( + +)); + +TactTaskTag.displayName = 'TactTaskTag'; + +export {TactTaskTag}; diff --git a/components/shared/TaskQuickEditor/TaskQuickEditorMainMenu.tsx b/components/shared/TaskQuickEditor/TaskQuickEditorMainMenu.tsx index d843c301..0130e6e4 100644 --- a/components/shared/TaskQuickEditor/TaskQuickEditorMainMenu.tsx +++ b/components/shared/TaskQuickEditor/TaskQuickEditorMainMenu.tsx @@ -1,5 +1,5 @@ import { observer } from 'mobx-react-lite'; -import { Modes, useTaskQuickEditorStore } from './store'; +import { useTaskQuickEditorStore } from './store'; import { chakra, IconButton, @@ -9,6 +9,13 @@ import { Portal, MenuList, } from '@chakra-ui/react'; +import { + faBullseyePointer, + faCircleExclamation, + faHashtag, + faSolarSystem, +} from '@fortawesome/pro-light-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { DotsIcon } from '../Icons/DotsIcon'; import React from 'react'; @@ -38,43 +45,67 @@ export const TaskQuickEditorMainMenu = observer(function TaskQuickEditMenu() { store.activateMode(Modes.TAG)} + p={2.5} + icon={ + + } + onClick={() => { + store.input.focus(); + store.modals.openAddTagModal(); + }} > - Add tag + Add hashtag store.activateMode(Modes.PRIORITY)} + p={2.5} + icon={ + + } + onClick={() => { + store.input.focus(); + store.modals.openPriorityModal(); + }} > Set priority {!store.disableGoalChange && ( store.activateMode(Modes.GOAL)} + p={2.5} + icon={ + + } + onClick={() => { + store.input.focus(); + store.modals.openGoalAssignModal(); + }} > - Add goal + Set goal )} {!store.disableSpaceChange && ( store.activateMode(Modes.SPACE)} + p={2.5} + icon={ + + } + onClick={(e) => { + store.input.focus(); + store.modals.openSpaceChangeModal(); + }} > - Link to space + Change space )} diff --git a/components/shared/TaskQuickEditor/TaskQuickEditorTags.tsx b/components/shared/TaskQuickEditor/TaskQuickEditorTags.tsx index f2f885a6..c9ec6296 100644 --- a/components/shared/TaskQuickEditor/TaskQuickEditorTags.tsx +++ b/components/shared/TaskQuickEditor/TaskQuickEditorTags.tsx @@ -10,16 +10,13 @@ import { PopoverBody, PopoverContent, PopoverTrigger, - Tag, chakra, VStack, Portal, - IconButton, } from '@chakra-ui/react'; import React, { useEffect } from 'react'; -import { faXmark } from '@fortawesome/pro-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { TaskTag } from '../TasksList/types'; +import { TactTaskTag } from '../TactTaskTag'; export const TAGS_ID = 'task-quick-editor-tags'; @@ -33,95 +30,49 @@ const TaskQuickEditorTagsList = observer(function TaskQuickEditorTags({ const store = useTaskQuickEditorStore(); const renderContent = ({ title, id }: TaskTag) => ( - + store.modes.tag.setTagRef(el, id)} + buttonProps={{ + onClick: (e) => { + e.stopPropagation(); + store.modes.tag.focusTagById(id); + }, + onKeyDown: (e) => store.modes.tag.handleButtonKeyDown(e, id), + onFocus: store.handleModeFocus(Modes.TAG), + ...buttonProps, + }} + iconButtonProps={{ + onClick: (e) => { + e.stopPropagation(); + store.modes.tag.removeTag(id); + } + }} + title={title} + showRemoveIcon + /> ); return ( {store.modes.tag.tags.map((tag) => - disableAnimating - ? - - {renderContent(tag)} - - : ( - - {renderContent(tag)} - - ) + disableAnimating + ? + + {renderContent(tag)} + + : ( + + {renderContent(tag)} + + ) )} ); @@ -167,75 +118,75 @@ export const TaskQuickEditorTags = observer(function TaskQuickEditTags({ ref={store.modes.tag.setContainerRef} > {store.modes.tag.isCollapsed ? ( - - - - - - + + + + + - - - - - - - - + + + + + + + ) : ( )} diff --git a/components/shared/TaskQuickEditor/modals/store.ts b/components/shared/TaskQuickEditor/modals/store.ts index 67bf6da5..80ffcd53 100644 --- a/components/shared/TaskQuickEditor/modals/store.ts +++ b/components/shared/TaskQuickEditor/modals/store.ts @@ -4,18 +4,34 @@ import { SpaceCreationModal } from '../../../pages/Spaces/modals/SpaceCreationMo import { SpaceData } from '../../../pages/Spaces/types'; import { RootStore } from '../../../../stores/RootStore'; import { CreateGoalParams } from "../../../../stores/RootStore/Resources/GoalsStore"; +import { TaskGoalAssignModal } from '../../TasksList/modals/TaskGoalAssignModal'; +import { TaskSpaceChangeModal } from '../../TasksList/modals/TaskSpaceChangeModal'; +import { TaskAddTagModal } from '../../TasksList/modals/TaskAddTagModal'; +import { TaskQuickEditorStore } from '../store'; +import { TaskPriorityModal } from '../../TasksList/modals/TaskPriorityModal'; +import { TaskPriority, TaskTag } from '../../TasksList/types'; + export enum ModalsTypes { ADD_GOAL, ADD_SPACE, + GOAL_ASSIGN, + SPACE_CHANGE, + SPACE_CREATION, + ADD_TAG, + SET_PRIORITY, } export class TasksEditorModals { - constructor(public root: RootStore) { } + constructor(public root: RootStore, public parent: TaskQuickEditorStore) { } controller = new ModalsController({ [ModalsTypes.ADD_GOAL]: GoalCreationModal, [ModalsTypes.ADD_SPACE]: SpaceCreationModal, + [ModalsTypes.GOAL_ASSIGN]: TaskGoalAssignModal, + [ModalsTypes.SPACE_CHANGE]: TaskSpaceChangeModal, + [ModalsTypes.ADD_TAG]: TaskAddTagModal, + [ModalsTypes.SET_PRIORITY]: TaskPriorityModal, }); openGoalCreationModal = (cb?: () => void) => { @@ -59,4 +75,93 @@ export class TasksEditorModals { }, }); }; + + openGoalAssignModal = () => { + this.root.toggleModal(true); + this.controller.open({ + type: ModalsTypes.GOAL_ASSIGN, + props: { + callbacks: { + onClose: () => { + this.controller.close(); + this.root.toggleModal(false); + }, + onGoalCreateClick: () => { + this.openGoalCreationModal(this.openGoalAssignModal); + }, + onSelect: (goalId: string) => { + this.parent.modes.goal.selectedGoalId = goalId; + this.controller.close(); + this.root.toggleModal(false); + }, + }, + value: this.parent.modes.goal.selectedGoalId, + }, + }); + }; + + openSpaceChangeModal = () => { + this.root.toggleModal(true); + this.controller.open({ + type: ModalsTypes.SPACE_CHANGE, + props: { + callbacks: { + onClose: () => { + this.controller.close(); + this.root.toggleModal(false); + }, + onSpaceCreateClick: () => { + this.openSpaceCreationModal(this.openSpaceChangeModal); + }, + onSelect: (spaceId: string) => { + this.parent.modes.space.selectedSpaceId = spaceId; + this.controller.close(); + this.root.toggleModal(false); + }, + }, + spaceId: this.parent.modes.space.selectedSpaceId, + }, + }); + }; + + openAddTagModal = () => { + this.root.toggleModal(true); + this.controller.open({ + type: ModalsTypes.ADD_TAG, + props: { + callbacks: { + onSave: (tags: TaskTag[]) => { + this.parent.modes.tag.updateTags(tags); + this.controller.close(); + }, + onClose: () => { + this.controller.close(); + this.root.toggleModal(false); + }, + }, + tags: this.parent.modes.tag.tags.map(({ id }) => id), + }, + }); + }; + + openPriorityModal = () => { + this.root.toggleModal(true); + this.controller.open({ + type: ModalsTypes.SET_PRIORITY, + props: { + callbacks: { + onClose: () => { + this.controller.close(); + this.root.toggleModal(false); + }, + onSelect: (priority: TaskPriority) => { + this.parent.modes.priority.priority = priority; + this.controller.close(); + this.root.toggleModal(false); + }, + }, + priority: this.parent.modes.priority.priority, + }, + }); + }; } diff --git a/components/shared/TaskQuickEditor/modes/TagModeStore.tsx b/components/shared/TaskQuickEditor/modes/TagModeStore.tsx index f97ef709..38ec1330 100644 --- a/components/shared/TaskQuickEditor/modes/TagModeStore.tsx +++ b/components/shared/TaskQuickEditor/modes/TagModeStore.tsx @@ -215,6 +215,10 @@ export class TagModeStore { } }; + updateTags = (tags: TaskTag[]) => { + this.tags = tags + } + addTag = (tag: TaskTag) => { this.tags.push(tag); }; diff --git a/components/shared/TaskQuickEditor/store.ts b/components/shared/TaskQuickEditor/store.ts index 853b8cbf..8276ca93 100644 --- a/components/shared/TaskQuickEditor/store.ts +++ b/components/shared/TaskQuickEditor/store.ts @@ -76,7 +76,7 @@ export class TaskQuickEditorStore { onOpen: (isOpen: boolean) => this.callbacks.onSuggestionsMenuOpen?.(isOpen), }); - modals = new TasksEditorModals(this.root); + modals = new TasksEditorModals(this.root, this); order = [Modes.TAG, Modes.SPACE, Modes.PRIORITY, Modes.GOAL]; callbacks: TaskQuickEditorProps['callbacks']; @@ -701,7 +701,8 @@ export class TaskQuickEditorStore { this.disableGoalChange = disableGoalChange; this.disableReferenceChange = disableReferenceChange; - const defaultSpaceId = task?.spaceId || input?.spaceId; + const defaultSpaceId = task?.spaceId || input?.spaceId || this.modes.space.selectedSpaceId; + this.defaultSpaceId = externalDefaultSpaceId; this.defaultGoalId = defaultGoalId; diff --git a/components/shared/TasksList/components/TaskItemMenu/index.tsx b/components/shared/TasksList/components/TaskItemMenu/index.tsx index 9425450d..fb71d638 100644 --- a/components/shared/TasksList/components/TaskItemMenu/index.tsx +++ b/components/shared/TasksList/components/TaskItemMenu/index.tsx @@ -2,7 +2,6 @@ import { observer } from 'mobx-react-lite'; import { TaskItemStore, useTaskItemStore } from '../TaskItem/store'; import React from 'react'; import { TaskStatus } from '../../types'; -import { Modes } from '../../../TaskQuickEditor/store'; import { faBan, faBullseyePointer, @@ -78,18 +77,16 @@ const multiTaskItems = (store: TaskItemStore) => [ const singleTaskItems = (store: TaskItemStore) => [ { onClick: () => { - store.parent.setEditingTask(store.task.id); - setTimeout(() => store.quickEdit.activateMode(Modes.PRIORITY)); + store.parent.modals.openPriorityModal(store.task.id); }, title: 'Change priority', icon: faCircleExclamation, }, { onClick: () => { - store.parent.setEditingTask(store.task.id); - setTimeout(() => store.quickEdit.activateMode(Modes.TAG)); + store.parent.modals.openAddTagModal(store.task.id); }, - title: 'Add tag', + title: 'Add hashtag', icon: faHashtag, }, { diff --git a/components/shared/TasksList/modals/TaskAddTagModal/index.tsx b/components/shared/TasksList/modals/TaskAddTagModal/index.tsx new file mode 100644 index 00000000..3cef750c --- /dev/null +++ b/components/shared/TasksList/modals/TaskAddTagModal/index.tsx @@ -0,0 +1,11 @@ +import { observer } from 'mobx-react-lite'; +import { TaskAddTagModalView } from './view'; +import { TaskAddTagModalProps, TaskAddTagModalStoreProvider } from './store'; + +export const TaskAddTagModal = observer(function TaskAddTagModal(props: TaskAddTagModalProps) { + return ( + + + + ); +}); diff --git a/components/shared/TasksList/modals/TaskAddTagModal/store.tsx b/components/shared/TasksList/modals/TaskAddTagModal/store.tsx new file mode 100644 index 00000000..364256f0 --- /dev/null +++ b/components/shared/TasksList/modals/TaskAddTagModal/store.tsx @@ -0,0 +1,340 @@ +import React, { SyntheticEvent } from 'react'; +import { makeAutoObservable } from 'mobx'; +import { getProvider } from '../../../../../helpers/StoreProvider'; +import { RootStore } from '../../../../../stores/RootStore'; +import { TaskTag } from '../../types'; +import { v4 as uuidv4 } from 'uuid'; + +export type TaskAddTagModalProps = { + callbacks: { + onClose: () => void; + onSave: (tags: TaskTag[]) => void; + }, + tags: string[]; +}; + +export class TaskAddTagModalStore { + constructor(public root: RootStore) { + makeAutoObservable(this); + } + startSymbol = '#'; + selectedTags: TaskTag[]; + showSuggestions: boolean = false; + selectedTagsRefs = {}; + availableTagsRefs = {}; + suggestionsMenuRefs = {}; + inputRef = null; + blockInFocus: string; + + keyMap = { + FORCE_ENTER: ['meta+enter', 'ctrl+enter'], + LEFT: ['left'], + RIGHT: ['right'], + DOWN: ['down'], + UP: ['up'], + + }; + + hotkeyHandlers = { + FORCE_ENTER: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.handleSave(); + }, + LEFT: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.modalNavigate(e); + }, + RIGHT: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.modalNavigate(e); + }, + DOWN: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.modalNavigate(e); + }, + UP: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.modalNavigate(e); + }, + }; + + strValue: string = '#'; + callbacks: TaskAddTagModalProps['callbacks']; + + get availableTags() { + return this.root.resources.tags.list; + } + + get filteredTags() { + return this.availableTags.filter( + ({ title }) => + title.startsWith(this.strValue) && + !this.selectedTags.some(({ title: tagTitle }) => tagTitle === title) + ); + } + + get currentTagMatch() { + return this.filteredTags.some( + ({ title }) => title === this.strValue + ); + } + + get hasTag() { + return this.selectedTags.some( + ({ title: tagTitle }) => tagTitle === this.strValue + ); + } + + + get isTagCreationAvailable() { + return ( + this.strValue.length > 1 && !this.currentTagMatch && !this.hasTag + ); + } + + setRef = (type: string, index?: number) => (el) => { + switch (type) { + case 'input': + this.inputRef = el; + break; + case 'selectedTags': + this.selectedTagsRefs[index] = el; + break; + case 'availableTags': + this.availableTagsRefs[index] = el; + break; + case 'suggestionsMenu': + this.suggestionsMenuRefs[index] = el; + break; + } + } + + removeTag = (id: string, e?: SyntheticEvent) => { + if (e) { + const index = Number((e.target as HTMLButtonElement).name); + const ref = this.selectedTagsRefs + + if (this.selectedTags.length === 1) { + this.inputRef.focus(); + } else { + (ref?.[index - 1] ?? ref?.[index + 1]).focus(); + } + } + + this.selectedTags = this.selectedTags.filter((tag) => tag.id !== id); + }; + + addTag = (tag: TaskTag) => { + this.selectedTags.push(tag); + }; + + createNewTag = (newTagTitle: string) => { + const hasAvailableTag = this.availableTags.find( + ({ title: tagTitle }) => tagTitle === newTagTitle + ); + + if (!hasAvailableTag) { + const id = uuidv4(); + const newTag = { title: newTagTitle, id }; + this.addTag(newTag); + this.root.resources.tags.add(newTag); + return; + } + this.addTag(hasAvailableTag); + }; + + inputKeyDown = (e) => { + const value = e.target.value + const tagsLength = this.selectedTags.length + + if (this.showSuggestions && e.code === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + this.suggestionsMenuRefs[0].focus(); + this.changeFocusBlock('suggestionsMenu'); + } + + if (e.code === 'Backspace' && !value.length && tagsLength) { + this.removeTag(this.selectedTags[tagsLength - 1].id) + } + if (e.code !== 'Enter' && e.code !== 'Space') return + if (!value.trim()) { + e.target.value = '' + return + } + if (!this.hasTag) { + this.createNewTag('#' + value) + this.toggleSuggestion(); + e.target.value = '' + } + } + + modalNavigate = (e) => { + const focusedBlock = this.blockInFocus; + + if (focusedBlock === 'input') { + if (e.code === 'ArrowLeft' || (e.code === 'Tab' && e.shiftKey)) { + if (this.selectedTags.length) { + this.selectedTagsRefs[this.selectedTags.length - 1].focus(); + this.changeFocusBlock('selectedTags'); + } + } else if (e.code === 'ArrowRight' || e.code === 'Tab' || (!this.showSuggestions && e.code === 'ArrowDown')) { + if (this.availableTags.length) { + this.availableTagsRefs[0].focus(); + this.changeFocusBlock('availableTags'); + } + } + } else if (focusedBlock === 'suggestionsMenu') { + this.suggestionNavigate(e); + } else { + const tagsRefs = focusedBlock === 'selectedTags' ? this.selectedTagsRefs : this.availableTagsRefs + const index = Number(e.target.name); + + if (e.code === 'ArrowLeft' || (e.code === 'Tab' && e.shiftKey)) { + if (!!tagsRefs?.[index - 1]) { + tagsRefs[index - 1].focus(); + } else if (focusedBlock === 'availableTags') { + this.inputRef.focus(); + this.changeFocusBlock('input'); + } + } else if (e.code === 'ArrowRight' || e.code === 'Tab') { + if (!!tagsRefs?.[index + 1]) { + tagsRefs[index + 1].focus(); + } else if (focusedBlock === 'selectedTags') { + this.inputRef.focus(); + this.changeFocusBlock('input'); + } + } else if (e.code === 'ArrowUp' && focusedBlock === 'availableTags' && index === 0) { + this.inputRef.focus(); + this.changeFocusBlock('input'); + } + } + } + + suggestionNavigate = (e) => { + const index = Number(e.target.name); + const refs = this.suggestionsMenuRefs; + const maxIndex = this.suggestions.length - 1; + if (e.code === 'ArrowUp' || (e.code === 'Tab' && e.shiftKey)) { + if (index > 0) { + refs[index - 1].focus(); + } else { + this.inputRef.focus(); + this.changeFocusBlock('input'); + } + } else if ((e.code === 'ArrowDown' || e.code === 'Tab') && index < maxIndex) { + refs[index + 1].focus(); + } + } + + handleInputChange = (event) => { + const value = event.target.value + if ((value.length && !this.showSuggestions) || (!value.length && this.showSuggestions)) { + this.toggleSuggestion(); + } + + this.strValue = this.startSymbol + value + } + + handleFocusInput = (event) => { + this.changeFocusBlock('input') + if (event.target.value.length && !this.showSuggestions) { + this.toggleSuggestion(); + } + }; + + addAvailableTag = (id: string) => { + if (!this.availableTags.some(({ id: tagId }) => tagId === id)) { + const tag = this.availableTags.find((tag) => tag.id === id); + + this.addTag(tag); + } + }; + + handleSave = () => { + this.callbacks.onSave(this.selectedTags); + }; + + get suggestions() { + const hasCreateNewTag = this.isTagCreationAvailable; + const tags = this.filteredTags; + const items = tags.slice(0, 15).map(({ title }) => <>{title}); + + if (hasCreateNewTag) { + items.unshift( + <> + Create new " + {this.strValue.slice(1)}" tag + + ); + } else if ( + items.length === 0 && + tags.length === 0 && + this.strValue === this.startSymbol + ) { + items.push(<>Start typing to create a new tag); + } else if (this.hasTag) { + items.unshift(<>This tag is already used); + } + + return items; + } + + resetInput = () => { + this.inputRef.value = ''; + this.showSuggestions = false; + this.inputRef.focus(); + } + + handleSuggestionSelect = (index: number) => { + if (this.strValue !== this.startSymbol || this.filteredTags.length) { + if ( + index === 0 && + !this.currentTagMatch && + !this.hasTag && + this.strValue !== this.startSymbol + ) { + this.resetInput(); + this.createNewTag(this.strValue); + return; + } + + if (!this.hasTag || index > 0) { + const hasFirstItem = !this.currentTagMatch || this.hasTag + this.resetInput(); + this.addTag( + this.filteredTags[hasFirstItem ? index - 1 : index] + ); + } + } + }; + + toggleSuggestion = () => { + this.showSuggestions = !this.showSuggestions; + } + + changeFocusBlock = (type: string) => { + if (this.blockInFocus !== type) this.blockInFocus = type; + } + + update = ({ callbacks, tags }: TaskAddTagModalProps) => { + const selectedTags = this.availableTags.filter(({ id }) => tags.includes(id)); + this.selectedTags = selectedTags; + this.callbacks = callbacks; + if (selectedTags.length) { + this.blockInFocus = 'selectedTags'; + } else { + this.blockInFocus = 'input'; + } + }; +} + +export const { + StoreProvider: TaskAddTagModalStoreProvider, + useStore: useTaskAddTagModalStore, +} = getProvider(TaskAddTagModalStore); diff --git a/components/shared/TasksList/modals/TaskAddTagModal/view.tsx b/components/shared/TasksList/modals/TaskAddTagModal/view.tsx new file mode 100644 index 00000000..34616b98 --- /dev/null +++ b/components/shared/TasksList/modals/TaskAddTagModal/view.tsx @@ -0,0 +1,251 @@ +import { observer } from 'mobx-react-lite'; +import React, { useRef } from 'react'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/modal'; +import { + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Portal, + chakra, + Box, + Button, + Text, + HStack, + Input, + InputGroup, + InputLeftElement, +} from '@chakra-ui/react'; +import { + useTaskAddTagModalStore, +} from './store'; +import { useHotkeysHandler } from '../../../../../helpers/useHotkeysHandler'; +import { useOutsideClick } from '@chakra-ui/react-use-outside-click' +import { TactTaskTag } from '../../../TactTaskTag'; +import { isMac } from '../../../../../helpers/os'; + + +export const TaskAddTagModalView = observer(function TaskAddTagModalView() { + const store = useTaskAddTagModalStore(); + const ref = useRef(null); + + useHotkeysHandler(store.keyMap, store.hotkeyHandlers) + + useOutsideClick({ + enabled: store.showSuggestions, + ref: ref, + handler: store.toggleSuggestion, + }); + + return ( + + + + Add hashtag + + + Hashtags of the current task + + button, > div': { + 'margin-inline-start': '0px!important', + } + }} + > + {store.selectedTags?.map(({ title, id }, index) => ( + store.changeFocusBlock('selectedTags'), + onKeyDown: (e) => { + if (e.code === 'Backspace') { + store.removeTag(id, e) + } + } + }} + iconButtonProps={{ + onClick: (e) => { + e.stopPropagation(); + store.removeTag(id); + }, + }} + /> + ))} + + + {store.showSuggestions && ( + + + + e.stopPropagation()} + p={0} + boxShadow='lg' + minW={32} + maxW={72} + width='auto' + overflow='hidden' + > + + {store.suggestions.map((child, index) => ( + + ))} + + + + + )} + # + + + + {!!store.availableTags.length && ( + + All your hashtags + + + {store.availableTags.map(({ title, id }, index) => { + const alreadySelected = !!store.selectedTags.find(({ id: selectedId }) => selectedId === id) + return ( + store.changeFocusBlock('availableTags'), + onClick: (e) => alreadySelected ? store.removeTag(id) : store.addTag({ title, id }) + }} + /> + ) + })} + + )} + + + + + + + + + ); +}); diff --git a/components/shared/TasksList/modals/TaskGoalAssignModal/store.ts b/components/shared/TasksList/modals/TaskGoalAssignModal/store.ts index 45184371..46d92cfa 100644 --- a/components/shared/TasksList/modals/TaskGoalAssignModal/store.ts +++ b/components/shared/TasksList/modals/TaskGoalAssignModal/store.ts @@ -29,7 +29,7 @@ export class TaskGoalAssignModalStore { keyMap = { RESET: ['backspace', 'delete'], - FORCE_ENTER: ['meta+enter'], + FORCE_ENTER: ['meta+enter', 'ctrl+enter'], }; hotkeyHandlers = { diff --git a/components/shared/TasksList/modals/TaskGoalAssignModal/view.tsx b/components/shared/TasksList/modals/TaskGoalAssignModal/view.tsx index 09b296d2..ad274918 100644 --- a/components/shared/TasksList/modals/TaskGoalAssignModal/view.tsx +++ b/components/shared/TasksList/modals/TaskGoalAssignModal/view.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { GoalsSelection } from '../../../GoalsSelection'; import { useListNavigation } from '../../../../../helpers/ListNavigation'; import { useHotkeysHandler } from '../../../../../helpers/useHotkeysHandler'; +import { isMac } from '../../../../../helpers/os'; export const TaskGoalAssignModalView = observer( function TaskGoalAssignModalView() { @@ -43,27 +44,42 @@ export const TaskGoalAssignModalView = observer( }} /> - + diff --git a/components/shared/TasksList/modals/TaskPriorityModal/index.tsx b/components/shared/TasksList/modals/TaskPriorityModal/index.tsx new file mode 100644 index 00000000..3162e0de --- /dev/null +++ b/components/shared/TasksList/modals/TaskPriorityModal/index.tsx @@ -0,0 +1,16 @@ +import { observer } from 'mobx-react-lite'; +import { TaskPriorityModalView } from './view'; +import { + TaskPriorityModalProps, + TaskPriorityModalStoreProvider, +} from './store'; + +export const TaskPriorityModal = observer(function TaskPriorityModal( + props: TaskPriorityModalProps +) { + return ( + + + + ); +}); diff --git a/components/shared/TasksList/modals/TaskPriorityModal/store.ts b/components/shared/TasksList/modals/TaskPriorityModal/store.ts new file mode 100644 index 00000000..4fbdf3c3 --- /dev/null +++ b/components/shared/TasksList/modals/TaskPriorityModal/store.ts @@ -0,0 +1,59 @@ +import { makeAutoObservable } from 'mobx'; +import { RootStore } from '../../../../../stores/RootStore'; +import { getProvider } from '../../../../../helpers/StoreProvider'; +import { ListNavigation } from '../../../../../helpers/ListNavigation'; +import { TaskPriority } from '../../types'; + +export type TaskPriorityModalProps = { + callbacks: { + onClose?: () => void; + onSelect?: (priority: TaskPriority) => void; + }; + priority: TaskPriority; +}; + +export class TaskPriorityModalStore { + constructor(public root: RootStore) { + makeAutoObservable(this); + } + + callbacks: TaskPriorityModalProps['callbacks'] = {}; + + selectedPriority: TaskPriority; + emptyRef: HTMLInputElement | null = null; + multiple: boolean = false; + + keyMap = { + FORCE_ENTER: ['meta+enter', 'ctrl+enter'], + }; + + hotkeyHandlers = { + FORCE_ENTER: (e) => { + e.preventDefault(); + e.stopPropagation(); + this.navigation.hotkeyHandlers.FORCE_ENTER?.(e); + }, + }; + + handleSelect = (priority: TaskPriority[]) => { + this.selectedPriority = priority[0]; + }; + + handleSubmit = () => { + this.callbacks.onSelect?.(this.selectedPriority); + }; + + update = ({ priority, callbacks }: TaskPriorityModalProps) => { + this.callbacks = callbacks; + this.selectedPriority = priority; + }; + + navigation = new ListNavigation({ + onForceEnter: this.handleSubmit, + }); +} + +export const { + StoreProvider: TaskPriorityModalStoreProvider, + useStore: useTaskPriorityModalStore, +} = getProvider(TaskPriorityModalStore); diff --git a/components/shared/TasksList/modals/TaskPriorityModal/view.tsx b/components/shared/TasksList/modals/TaskPriorityModal/view.tsx new file mode 100644 index 00000000..0c0680e8 --- /dev/null +++ b/components/shared/TasksList/modals/TaskPriorityModal/view.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/modal'; +import { Button, Text } from '@chakra-ui/react'; +import { useTaskPriorityModalStore } from './store'; +import { useListNavigation } from '../../../../../helpers/ListNavigation'; +import { useHotkeysHandler } from '../../../../../helpers/useHotkeysHandler'; +import { PrioritySelection } from '../../../PrioritySelection'; +import { isMac } from '../../../../../helpers/os'; + +export const TaskPriorityModalView = observer( + function TaskPriorityModalView() { + const store = useTaskPriorityModalStore(); + + useListNavigation(store.navigation); + useHotkeysHandler(store.keyMap, store.hotkeyHandlers) + + return ( + + + + Set priority + + + + + + + + + + ); + } +); diff --git a/components/shared/TasksList/modals/TaskSpaceChangeModal/store.ts b/components/shared/TasksList/modals/TaskSpaceChangeModal/store.ts index 7289aefe..4f638a25 100644 --- a/components/shared/TasksList/modals/TaskSpaceChangeModal/store.ts +++ b/components/shared/TasksList/modals/TaskSpaceChangeModal/store.ts @@ -1,18 +1,16 @@ -import { makeAutoObservable, toJS } from 'mobx'; +import { makeAutoObservable } from 'mobx'; import { RootStore } from '../../../../../stores/RootStore'; import { getProvider } from '../../../../../helpers/StoreProvider'; import { SpacesSelectionStore } from '../../../SpacesSelection/store'; import { ListNavigation } from '../../../../../helpers/ListNavigation'; -import { TaskData } from '../../types'; export type TaskSpaceChangeModalProps = { callbacks: { onClose?: () => void; - onSelect?: (updatedTask: TaskData) => void; + onSelect?: (spaceId: string) => void; onSpaceCreateClick?: () => void; }; multiple?: boolean; - task: TaskData; spaceId: string; }; @@ -25,7 +23,6 @@ export class TaskSpaceChangeModalStore { spacesSelection = new SpacesSelectionStore(this.root); - selectedTask: TaskData = {} as TaskData; emptyRef: HTMLInputElement | null = null; selectedSpaceId: string | null = null; multiple: boolean = false; @@ -47,11 +44,7 @@ export class TaskSpaceChangeModalStore { }; handleSubmit = () => { - this.callbacks.onSelect?.({ - ...this.selectedTask, - spaceId: this.selectedSpaceId, - tags: toJS(this.selectedTask.tags) - }); + this.callbacks.onSelect?.(this.selectedSpaceId); }; update = (props: TaskSpaceChangeModalProps) => { @@ -59,7 +52,6 @@ export class TaskSpaceChangeModalStore { this.multiple = props.multiple; this.selectedSpaceId = props.spaceId; - this.selectedTask = props.task; }; navigation = new ListNavigation({ diff --git a/components/shared/TasksList/modals/TaskSpaceChangeModal/view.tsx b/components/shared/TasksList/modals/TaskSpaceChangeModal/view.tsx index f405dde5..8a6b8c33 100644 --- a/components/shared/TasksList/modals/TaskSpaceChangeModal/view.tsx +++ b/components/shared/TasksList/modals/TaskSpaceChangeModal/view.tsx @@ -13,6 +13,7 @@ import { useTaskSpaceChangeModalStore } from './store'; import { SpacesSelection } from '../../../SpacesSelection'; import { useListNavigation } from '../../../../../helpers/ListNavigation'; import { useHotkeysHandler } from '../../../../../helpers/useHotkeysHandler'; +import { isMac } from '../../../../../helpers/os'; export const TaskSpaceChangeModalView = observer( function TaskSpaceChangeModalView() { @@ -43,27 +44,42 @@ export const TaskSpaceChangeModalView = observer( }} /> - + diff --git a/components/shared/TasksList/modals/TaskWontDoModal/store.ts b/components/shared/TasksList/modals/TaskWontDoModal/store.ts index 006279f7..745c7c81 100644 --- a/components/shared/TasksList/modals/TaskWontDoModal/store.ts +++ b/components/shared/TasksList/modals/TaskWontDoModal/store.ts @@ -27,7 +27,7 @@ export class TaskWontDoModalStore { keyMap = { RESET: ['backspace', 'delete'], - FORCE_ENTER: ['meta+enter'], + FORCE_ENTER: ['meta+enter', 'ctrl+enter'], }; hotkeyHandlers = { diff --git a/components/shared/TasksList/modals/TaskWontDoModal/view.tsx b/components/shared/TasksList/modals/TaskWontDoModal/view.tsx index 0c463198..d2e37f4d 100644 --- a/components/shared/TasksList/modals/TaskWontDoModal/view.tsx +++ b/components/shared/TasksList/modals/TaskWontDoModal/view.tsx @@ -24,6 +24,7 @@ import { } from './store'; import { useListNavigation } from '../../../../../helpers/ListNavigation'; import { useHotkeysHandler } from '../../../../../helpers/useHotkeysHandler'; +import { isMac } from '../../../../../helpers/os'; export const TaskWontDoModalView = observer(function TaskWontDoModalView({ onClose, @@ -93,7 +94,7 @@ export const TaskWontDoModalView = observer(function TaskWontDoModalView({ > Save - ⌘ + Enter + {`${isMac() ? '⌘' : 'Ctrl'} + Enter`} diff --git a/components/shared/TasksList/modals/store.ts b/components/shared/TasksList/modals/store.ts index 2480520a..c9f592ce 100644 --- a/components/shared/TasksList/modals/store.ts +++ b/components/shared/TasksList/modals/store.ts @@ -5,10 +5,14 @@ import { GoalCreationModal } from '../../../pages/Goals/modals/GoalCreationModal import { TasksListStore } from '../store'; import { TaskWontDoModal } from './TaskWontDoModal'; import { TaskSpaceChangeModal } from './TaskSpaceChangeModal'; -import { TaskData } from '../types'; +import { TaskPriority, TaskTag } from '../types'; import { SpaceCreationModal } from '../../../pages/Spaces/modals/SpaceCreationModal'; import { SpaceData } from '../../../pages/Spaces/types'; import { CreateGoalParams } from "../../../../stores/RootStore/Resources/GoalsStore"; +import { TaskAddTagModal } from './TaskAddTagModal'; +import { TaskPriorityModal } from './TaskPriorityModal'; +import { toJS } from 'mobx'; + export enum ModalsTypes { DELETE_TASK, @@ -17,6 +21,8 @@ export enum ModalsTypes { GOAL_CREATION, SPACE_CHANGE, SPACE_CREATION, + ADD_TAG, + SET_PRIORITY, } export class TasksModals { @@ -29,6 +35,8 @@ export class TasksModals { [ModalsTypes.GOAL_CREATION]: GoalCreationModal, [ModalsTypes.SPACE_CHANGE]: TaskSpaceChangeModal, [ModalsTypes.SPACE_CREATION]: SpaceCreationModal, + [ModalsTypes.ADD_TAG]: TaskAddTagModal, + [ModalsTypes.SET_PRIORITY]: TaskPriorityModal, }); openVerifyDeleteModal = (ids: string[], done?: () => void) => { @@ -59,6 +67,26 @@ export class TasksModals { }); }; + openAddTagModal = (taskId: string) => { + const task = this.parent.items[taskId]; + this.controller.open({ + type: ModalsTypes.ADD_TAG, + props: { + callbacks: { + onSave: (tags: TaskTag[]) => { + this.parent.updateTask({ + ...task, + tags: tags.map(({ id }) => id), + }); + this.controller.close(); + }, + onClose: this.controller.close, + }, + tags: task.tags, + }, + }); + }; + openGoalCreationModal = (cb?: (goalId?: string) => void) => { this.controller.open({ type: ModalsTypes.GOAL_CREATION, @@ -76,6 +104,27 @@ export class TasksModals { }); }; + openPriorityModal = (taskId: string) => { + const task = this.parent.items[taskId]; + this.controller.open({ + type: ModalsTypes.SET_PRIORITY, + props: { + callbacks:{ + onClose: this.controller.close, + onSelect: (priority: TaskPriority) => { + this.parent.updateTask({ + ...task, + tags: toJS(task.tags), + priority, + }); + this.controller.close(); + }, + }, + priority: task.priority, + }, + }); + }; + openGoalAssignModal = (taskId?: string, startGoalId?: string) => { const focused = this.parent.draggableList.focused; const value = @@ -147,12 +196,15 @@ export class TasksModals { this.openSpaceChangeModal(taskId, spaceId); }); }, - onSelect: (updatedTask: TaskData) => { - this.parent.updateTask(updatedTask); + onSelect: (spaceId: string) => { + this.parent.updateTask({ + ...task, + spaceId, + tags: toJS(task.tags) + }); this.controller.close(); }, }, - task, spaceId, }, }); diff --git a/helpers/ListNavigation.ts b/helpers/ListNavigation.ts index 3b82c4f2..907dfd6a 100644 --- a/helpers/ListNavigation.ts +++ b/helpers/ListNavigation.ts @@ -43,7 +43,7 @@ export class ListNavigation { UP: ['up', 'j'], DOWN: ['down', 'k'], ENTER: ['enter'], - FORCE_ENTER: ['meta+enter'], + FORCE_ENTER: ['meta+enter', 'ctrl+enter'], FIRST: ['meta+up', 'meta+j', 'h'], LAST: ['meta+down', 'meta+k', 'l'], NUMBERS: numbers,