diff --git a/.editorconfig b/.editorconfig index 88ceee89..9350e0de 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,6 +22,9 @@ indent_size = 2 [*.tsx] indent_size = 2 +[*.ts] +indent_size = 2 + [*.scss] indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5f2e0bb..8b5e8af3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.1'] db-type: [sqlite, mysql, pgsql] env: @@ -45,9 +45,9 @@ jobs: - name: composer install run: | if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then - composer update --no-progress --prefer-lowest --prefer-stable + composer install --no-progress --prefer-lowest --prefer-stable --ignore-platform-req=php else - composer update --no-progress + composer install --no-progress --ignore-platform-req=php fi cp ./tests/Fixture/google-auth.json ./config/google-auth.json @@ -107,13 +107,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 7.4 + php-version: 8.1 extensions: mbstring, intl, pdo_mysql coverage: pcov - name: Install dependencies & build javascript run: | - composer update --no-progress + composer install --no-progress --ignore-platform-req=php yarn install yarn build @@ -165,13 +165,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.1' extensions: mbstring, intl coverage: none tools: phive - name: composer install - run: composer update --no-progress + run: composer install --no-progress --ignore-platform-req=php - name: Install PHP tools with phive. run: phive install --trust-gpg-keys '12CE0F1D262429A5' diff --git a/assets/js/Pages/CalendarProviders/Index.tsx b/assets/js/Pages/CalendarProviders/Index.tsx deleted file mode 100644 index 9f260f77..00000000 --- a/assets/js/Pages/CalendarProviders/Index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; -import {InertiaLink} from '@inertiajs/inertia-react'; -import {Fragment, useRef} from 'react'; - -import {t} from 'app/locale'; -import {deleteProvider} from 'app/actions/calendars'; -import LoggedIn from 'app/layouts/loggedIn'; -import Modal from 'app/components/modal'; -import OverflowActionBar from 'app/components/overflowActionBar'; -import {CalendarProviderDetailed, CalendarSource} from 'app/types'; -import {Icon, InlineIcon} from 'app/components/icon'; -import Tooltip from 'app/components/tooltip'; - -import CalendarSources from './calendarSources'; - -const REFERER_KEY = 'calendar-referer'; - -type Props = { - activeProvider: CalendarProviderDetailed; - providers: CalendarProviderDetailed[]; - referer: string; - unlinked: null | CalendarSource[]; -}; - -function CalendarProvidersIndex({activeProvider, providers, referer, unlinked}: Props) { - if (!localStorage.getItem(REFERER_KEY) && referer) { - localStorage.setItem(REFERER_KEY, referer); - } - function handleClose() { - const target = localStorage.getItem(REFERER_KEY) ?? '/tasks/today'; - localStorage.removeItem(REFERER_KEY); - Inertia.visit(target); - } - - const title = t('Synced Calendars'); - return ( - - -

{title}

-

- {t( - 'Events from linked calendars will be displayed in "today" and "upcoming" views.' - )} -

-
- - - {t('Add Google Account')} - -
- -

{t('Connected Calendar Accounts')}

- -
-
- ); -} -export default CalendarProvidersIndex; - -type ProviderProps = { - provider: CalendarProviderDetailed; - isActive: boolean; - unlinked: Props['unlinked']; -}; - -function CalendarProviderItem({isActive, provider, unlinked}: ProviderProps) { - async function handleDelete() { - await deleteProvider(provider); - } - - return ( -
  • -
    - {isActive && ( - - - - )} - {!isActive && ( - - - - )} -
    - , - }, - ]} - /> -
    -
    - {unlinked && ( -
    - -
    - )} -
  • - ); -} - -type CalendarProviderTileProps = { - provider: CalendarProviderDetailed; -}; - -function CalendarProviderTile({provider}: CalendarProviderTileProps) { - return ( - - - {provider.display_name} - {provider.broken_auth ? ( - - - - - - ) : null} - - ); -} - -function ProviderIcon({provider}: CalendarProviderTileProps) { - if (provider.kind === 'google') { - return ( - Google Calendar logo - ); - } - return ; -} diff --git a/assets/js/Pages/CalendarProviders/calendarSources.tsx b/assets/js/Pages/CalendarProviders/calendarSources.tsx deleted file mode 100644 index e3b53c30..00000000 --- a/assets/js/Pages/CalendarProviders/calendarSources.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; -import {useState} from 'react'; - -import {t} from 'app/locale'; -import {deleteSource} from 'app/actions/calendars'; -import {InlineIcon} from 'app/components/icon'; -import ColorSelect from 'app/components/colorSelect'; -import Modal from 'app/components/modal'; -import OverflowActionBar from 'app/components/overflowActionBar'; -import {PROJECT_COLORS} from 'app/constants'; -import LoggedIn from 'app/layouts/loggedIn'; -import {CalendarProviderDetailed, CalendarSource} from 'app/types'; - -type Props = { - calendarProvider: CalendarProviderDetailed; - unlinked: CalendarSource[]; -}; - -function CalendarSources({calendarProvider, unlinked}: Props) { - return ( - - ); -} -export default CalendarSources; - -type ItemProps = { - source: CalendarSource; - provider: CalendarProviderDetailed; - mode: 'create' | 'edit'; -}; - -function CalendarSourceItem({source, mode, provider}: ItemProps) { - const [refreshing, setRefreshing] = useState(false); - - function handleSync() { - setRefreshing(true); - Inertia.post(`/calendars/${provider.id}/sources/${source.id}/sync`, {}, { - onFinish() { - setRefreshing(false); - } - }); - } - - async function handleDelete() { - return deleteSource(provider, source); - } - - function handleCreate(event: React.MouseEvent) { - event.stopPropagation(); - const data = { - calendar_provider_id: provider.id, - provider_id: source.provider_id, - name: source.name, - color: source.color, - }; - Inertia.post(`/calendars/${provider.id}/sources/add`, data); - } - - function handleChange(color: number | string) { - const data = { - color, - }; - Inertia.post(`/calendars/${provider.id}/sources/${source.id}/edit`, data); - } - - return ( -
  • - - {source.id ? ( - - ) : ( - - )} - - {source.name} - - -
    - {mode === 'edit' && ( - , - label: t('Refresh'), - disabled: refreshing, - }, - { - buttonClass: 'button-danger', - menuItemClass: 'delete', - onSelect: handleDelete, - icon: , - label: t('Unlink'), - }, - ]} - /> - )} - {mode === 'create' && ( - - )} -
    -
  • - ); -} diff --git a/assets/js/Pages/CalendarSources/Add.tsx b/assets/js/Pages/CalendarSources/Add.tsx deleted file mode 100644 index 6638b25a..00000000 --- a/assets/js/Pages/CalendarSources/Add.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import {Fragment} from 'react'; -import {Inertia} from '@inertiajs/inertia'; - -import {t} from 'app/locale'; -import {deleteSource} from 'app/actions/calendars'; -import {InlineIcon} from 'app/components/icon'; -import ColorSelect from 'app/components/colorSelect'; -import Modal from 'app/components/modal'; -import OverflowActionBar from 'app/components/overflowActionBar'; -import {PROJECT_COLORS} from 'app/constants'; -import LoggedIn from 'app/layouts/loggedIn'; -import {CalendarProviderDetailed, CalendarSource} from 'app/types'; - -type Props = { - calendarProvider: CalendarProviderDetailed; - unlinked: CalendarSource[]; -}; - -function CalendarSourcesAdd({calendarProvider, unlinked}: Props) { - function handleClose() { - history.back(); - } - - const title = t('Linked Calendars'); - return ( - - -

    {t('Synced Calendars for {name}', {name: calendarProvider.display_name})}

    -

    - {t( - `The following calendars are synced into Docket. - You should see calendar events in your 'today' and 'upcoming' views.` - )} -

    -
      - {calendarProvider.calendar_sources.map(source => { - return ( - - ); - })} - {calendarProvider.calendar_sources.length == 0 && ( -
    • - - {t('You have no synchronized calendars in this provider. Add one below.')} -
    • - )} -
    -

    {t('Unwatched Calendars')}

    -
      - {unlinked.map(source => { - return ( - - ); - })} -
    -
    -
    - ); -} -export default CalendarSourcesAdd; - -type ItemProps = { - source: CalendarSource; - provider: CalendarProviderDetailed; - mode: 'create' | 'edit'; -}; - -function CalendarSourceItem({source, mode, provider}: ItemProps) { - function handleSync() { - Inertia.post(`/calendars/${provider.id}/sources/${source.id}/sync`); - } - - async function handleDelete() { - return deleteSource(provider, source); - } - - function handleCreate(event: React.MouseEvent) { - event.stopPropagation(); - const data = { - calendar_provider_id: provider.id, - provider_id: source.provider_id, - name: source.name, - color: source.color, - }; - Inertia.post(`/calendars/${provider.id}/sources/add`, data); - } - - function handleChange(color: number | string) { - const data = { - color, - }; - Inertia.post(`/calendars/${provider.id}/sources/${source.id}/edit`, data); - } - - return ( -
  • - - {source.id ? ( - - ) : ( - - )} - - {source.name} - {provider.display_name} - - -
    - {mode === 'edit' && ( - , - label: t('Refresh'), - }, - { - buttonClass: 'button-danger', - menuItemClass: 'delete', - onSelect: handleDelete, - icon: , - label: t('Unlink'), - }, - ]} - /> - )} - {mode === 'create' && ( - - )} -
    -
  • - ); -} diff --git a/assets/js/Pages/Projects/Add.tsx b/assets/js/Pages/Projects/Add.tsx deleted file mode 100644 index 2a824aaa..00000000 --- a/assets/js/Pages/Projects/Add.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; - -import {t} from 'app/locale'; -import FormControl from 'app/components/formControl'; -import LoggedIn from 'app/layouts/loggedIn'; -import ColorSelect from 'app/components/colorSelect'; -import Modal from 'app/components/modal'; -import {ValidationErrors} from 'app/types'; - -type Props = { - errors?: ValidationErrors; - referer: string; -}; - -function ProjectsAdd({errors, referer}: Props) { - function handleCancel(event: React.MouseEvent) { - event.preventDefault(); - handleClose(); - } - - function handleClose() { - Inertia.visit(referer); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - - Inertia.post('/projects/add', formData); - } - - return ( - - -

    {t('New Project')}

    -
    - - - } - errors={errors} - required - /> -
    - - -
    - -
    -
    - ); -} -export default ProjectsAdd; diff --git a/assets/js/Pages/Projects/Archived.tsx b/assets/js/Pages/Projects/Archived.tsx deleted file mode 100644 index 586b99be..00000000 --- a/assets/js/Pages/Projects/Archived.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import {t} from 'app/locale'; -import {Project} from 'app/types'; -import LoggedIn from 'app/layouts/loggedIn'; -import ProjectItem from 'app/components/projectItem'; - -type Props = { - archived: Project[]; -}; - -export default function ProjectsArchived({archived}: Props) { - return ( - -

    {t('Archived Projects')}

    - {archived.length === 0 && } - {archived.map(item => ( - - ))} -
    - ); -} - -function NoProjects() { - return ( -
    -

    {t('Nothing to see')}

    -

    {t("You don't have any archived projects.")}

    -
    - ); -} diff --git a/assets/js/Pages/Projects/Edit.tsx b/assets/js/Pages/Projects/Edit.tsx deleted file mode 100644 index c48b8856..00000000 --- a/assets/js/Pages/Projects/Edit.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; - -import {t} from 'app/locale'; -import Modal from 'app/components/modal'; -import {Project, ValidationErrors} from 'app/types'; -import ColorSelect from 'app/components/colorSelect'; -import FormControl from 'app/components/formControl'; -import LoggedIn from 'app/layouts/loggedIn'; -import ToggleCheckbox from 'app/components/toggleCheckbox'; - -type Props = { - project: Project; - referer: string; - errors?: ValidationErrors; -}; - -export default function ProjectsEdit({project, errors, referer}: Props) { - function handleClose() { - Inertia.visit(referer); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - Inertia.post(`/projects/${project.slug}/edit`, formData); - } - const title = t('Edit {project} Project', {project: project.name}); - - return ( - - -

    {t('Edit Project')}

    -
    - - - } - errors={errors} - /> - ( - - - - - )} - errors={errors} - required - /> -
    - - -
    - -
    -
    - ); -} diff --git a/assets/js/Pages/Projects/View.tsx b/assets/js/Pages/Projects/View.tsx deleted file mode 100644 index fb6d0119..00000000 --- a/assets/js/Pages/Projects/View.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import {Fragment, useState} from 'react'; -import {createPortal} from 'react-dom'; -import {DragOverlay} from '@dnd-kit/core'; -import {InertiaLink} from '@inertiajs/inertia-react'; - -import {t} from 'app/locale'; -import {Project, Task} from 'app/types'; -import AddTaskButton from 'app/components/addTaskButton'; -import DragHandle from 'app/components/dragHandle'; -import {Icon} from 'app/components/icon'; -import LoggedIn from 'app/layouts/loggedIn'; -import ProjectMenu from 'app/components/projectMenu'; -import SectionAddForm from 'app/components/sectionAddForm'; -import SectionContainer from 'app/components/sectionContainer'; -import ProjectSectionSorter from 'app/components/projectSectionSorter'; -import ProjectRenameForm from 'app/components/projectRenameForm'; -import TaskGroup from 'app/components/taskGroup'; -import TaskList from 'app/components/taskList'; -import TaskRow from 'app/components/taskRow'; -import useKeyboardListNav from 'app/hooks/useKeyboardListNav'; - -type Props = { - project: Project; - tasks: Task[]; - completed?: Task[]; -}; - -export default function ProjectsView({completed, project, tasks}: Props): JSX.Element { - const [showAddSection, setShowAddSection] = useState(false); - const [editingName, setEditingName] = useState(false); - - function handleCancelSection() { - setShowAddSection(false); - } - - function handleCancelRename() { - setEditingName(false); - } - const [focusedIndex] = useKeyboardListNav(tasks.length); - let focused: null | Task = null; - if (focusedIndex >= 0 && tasks[focusedIndex] !== undefined) { - focused = tasks[focusedIndex]; - } - - return ( - -
    -
    -
    - {editingName ? ( - - ) : ( -

    setEditingName(true)}> - {project.archived && } - {project.name} -

    - )} - {!project.archived && !editingName && ( - - )} -
    - - setShowAddSection(true)} - showDetailed - /> -
    - - - {({groups, activeTask, activeSection}) => { - const elements = groups.map(({key, section, tasks}) => { - if (section === undefined) { - return ( - - ); - } - return ( - - - - ); - }); - - return ( - - {elements} - {createPortal( - - {activeTask ? ( -
    - - -
    - ) : activeSection ? ( -
    - -

    {activeSection.name}

    -
    - ) : null} -
    , - document.body - )} -
    - ); - }} -
    - {showAddSection && ( - - )} - {completed && ( - - -
    - - {t('Hide completed tasks')} - -
    -
    - )} -
    -
    - ); -} diff --git a/assets/js/Pages/Tasks/Daily.tsx b/assets/js/Pages/Tasks/Daily.tsx deleted file mode 100644 index fe4f7f3b..00000000 --- a/assets/js/Pages/Tasks/Daily.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import {Fragment} from 'react'; -import {SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable'; - -import {CalendarItem, Project, Task} from 'app/types'; -import {sortUpdater} from 'app/actions/tasks'; -import {t} from 'app/locale'; -import AddTaskButton from 'app/components/addTaskButton'; -import CalendarItemList from 'app/components/calendarItemList'; -import {Icon} from 'app/components/icon'; -import NoProjects from 'app/components/noProjects'; -import TaskGroup from 'app/components/taskGroup'; -import useKeyboardListNav from 'app/hooks/useKeyboardListNav'; -import LoggedIn from 'app/layouts/loggedIn'; -import TaskGroupedSorter, {GroupedItems} from 'app/components/taskGroupedSorter'; -import {parseDate, formatCompactDate, toDateString} from 'app/utils/dates'; - -type Props = { - date: string; - tasks: Task[]; - projects: Project[]; - calendarItems: CalendarItem[]; -}; - -function grouper(items: Task[]): GroupedItems { - const today = toDateString(new Date()); - const groups = items.reduce>( - (acc, item) => { - if (item.due_on !== today) { - acc.overdue.push(item); - return acc; - } - if (item.evening) { - acc.evening.push(item); - return acc; - } - acc.today.push(item); - return acc; - }, - {today: [], overdue: [], evening: []} - ); - const output = [ - { - key: 'overdue', - items: groups.overdue, - ids: groups.overdue.map(task => String(task.id)), - }, - { - key: today, - items: groups.today, - ids: groups.today.map(task => String(task.id)), - }, - { - key: `evening:${today}`, - items: groups.evening, - ids: groups.evening.map(task => String(task.id)), - }, - ]; - return output; -} - -export default function TasksDaily({ - calendarItems, - date, - tasks, - projects, -}: Props): JSX.Element { - const parsedDate = parseDate(date); - const dateLabel = formatCompactDate(parsedDate); - const title = t('{date} Tasks', {date: dateLabel}); - if (!projects.length) { - return ( - - - - ); - } - - const [focusedIndex] = useKeyboardListNav(tasks.length); - let focused: null | Task = null; - if (focusedIndex >= 0 && tasks[focusedIndex] !== undefined) { - focused = tasks[focusedIndex]; - } - - return ( - - - {({groupedItems, activeTask}) => { - const [overdue, today, evening] = groupedItems; - return ( - - {overdue.ids.length > 0 && ( - -

    - - {t('Overdue')} -

    - -
    - )} - -

    - - {dateLabel} - -

    - {calendarItems.length > 0 && ( - - )} - -
    - -

    - - {t('This Evening')} - -

    - -
    -
    - ); - }} -
    -
    - ); -} diff --git a/assets/js/Pages/Tasks/Deleted.tsx b/assets/js/Pages/Tasks/Deleted.tsx deleted file mode 100644 index e1de4f9c..00000000 --- a/assets/js/Pages/Tasks/Deleted.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import {Project, Task} from 'app/types'; -import {t} from 'app/locale'; -import {Icon} from 'app/components/icon'; -import NoProjects from 'app/components/noProjects'; -import TaskRow from 'app/components/taskRow'; -import LoggedIn from 'app/layouts/loggedIn'; - -type Props = { - tasks: Task[]; - projects: Project[]; -}; - -export default function TasksToday({tasks, projects}: Props): JSX.Element { - const title = t('Trash Bin'); - if (!projects.length) { - return ( - - - - ); - } - - return ( - -

    - - {t('Trash Bin')} -

    -

    {t('Trashed tasks will be deleted permanently after 14 days')}

    - {tasks.map(task => { - return ; - })} -
    - ); -} diff --git a/assets/js/Pages/Tasks/Index.tsx b/assets/js/Pages/Tasks/Index.tsx deleted file mode 100644 index 7ac9d3d8..00000000 --- a/assets/js/Pages/Tasks/Index.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import {Fragment} from 'react'; -import groupBy from 'lodash.groupby'; -import {SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable'; -import {InertiaLink} from '@inertiajs/inertia-react'; - -import {sortUpdater} from 'app/actions/tasks'; -import {t} from 'app/locale'; -import {CalendarItem, Project, Task} from 'app/types'; -import AddTaskButton from 'app/components/addTaskButton'; -import CalendarItemList from 'app/components/calendarItemList'; -import {InlineIcon} from 'app/components/icon'; -import LoggedIn from 'app/layouts/loggedIn'; -import NoProjects from 'app/components/noProjects'; -import TaskGroup from 'app/components/taskGroup'; -import TaskGroupedSorter, {GroupedItems} from 'app/components/taskGroupedSorter'; -import useKeyboardListNav from 'app/hooks/useKeyboardListNav'; - -import { - addDays, - toDateString, - formatDateHeading, - getRangeInDays, - parseDate, -} from 'app/utils/dates'; - -type Props = { - tasks: Task[]; - projects: Project[]; - calendarItems: CalendarItem[]; - start: string; - nextStart: string; - generation: string; -}; - -/** - * Fill out the sparse input data to have all the days. - */ -function zeroFillItems( - start: string, - numDays: number, - groups: GroupedItems -): GroupedItems { - const firstDate = parseDate(start); - const endDate = addDays(firstDate, numDays); - - const complete: GroupedItems = []; - let date = new Date(firstDate); - while (true) { - if (date && date > endDate) { - break; - } - const dateKey = toDateString(date); - if (groups.length && groups[0].key === dateKey) { - const values = groups.shift(); - if (values) { - complete.push(values); - } - } else { - complete.push({key: dateKey, items: [], ids: []}); - } - - if (groups.length && groups[0].key === `evening:${dateKey}`) { - const values = groups.shift(); - if (values) { - complete.push(values); - } - } - - // Increment for next loop. We are using a while/break - // because incrementing timestamps fails when DST happens. - date = addDays(date, 1); - } - return complete; -} - -function createGrouper(start: string, numDays: number) { - return function taskGrouper(items: Task[]): GroupedItems { - const byDate: Record = groupBy(items, item => { - if (item.evening) { - return `evening:${item.due_on}`; - } - return item.due_on ? item.due_on : t('No Due Date'); - }); - - const grouped = Object.entries(byDate).map(([key, value]) => { - return { - key, - items: value, - ids: value.map(task => String(task.id)), - }; - }); - return zeroFillItems(start, numDays, grouped); - }; -} - -type GroupedCalendarItems = Record; - -function groupCalendarItems(items: CalendarItem[]): GroupedCalendarItems { - return items.reduce((acc, item) => { - let keys = []; - if (item.all_day) { - keys = getRangeInDays(parseDate(item.start_date), parseDate(item.end_date)); - } else { - keys = getRangeInDays(new Date(item.start_time), new Date(item.end_time)); - } - - keys.forEach(key => { - if (typeof acc[key] === 'undefined') { - acc[key] = []; - } - acc[key].push(item); - }); - - return acc; - }, {}); -} - -export default function TasksIndex({ - calendarItems, - generation, - tasks, - projects, - start, - nextStart, -}: Props): JSX.Element { - const nextPage = nextStart ? `/tasks/upcoming?start=${nextStart}` : null; - const title = t('Upcoming Tasks'); - - const [focusedIndex] = useKeyboardListNav(tasks.length); - let focused: null | Task = null; - if (focusedIndex >= 0 && tasks[focusedIndex] !== undefined) { - focused = tasks[focusedIndex]; - } - - if (!projects.length) { - return ( - - - - ); - } - - const groupedCalendarItems = groupCalendarItems(calendarItems); - - return ( - -

    Upcoming

    - - {({groupedItems, activeTask}) => { - return ( - - {groupedItems.map(({key, ids, items}) => { - const [heading, subheading] = formatDateHeading(key); - - const eveningValue = key.includes('evening:'); - const dateValue = eveningValue ? key.split(':')[1] : key; - const defaultValues = {due_on: dateValue, evening: eveningValue}; - return ( - - {key.includes('evening') ? ( -
    - {t('Evening')} -
    - ) : ( -

    - {heading} - {subheading && {subheading}} - -

    - )} - {groupedCalendarItems[key] && ( - - )} - - - -
    - ); - })} -
    - ); - }} -
    -
    - {nextPage && ( - - {t('Next')} - - )} -
    -
    - ); -} diff --git a/assets/js/Pages/Tasks/View.tsx b/assets/js/Pages/Tasks/View.tsx deleted file mode 100644 index b40578e4..00000000 --- a/assets/js/Pages/Tasks/View.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import {useState} from 'react'; -import axios from 'axios'; -import {Inertia} from '@inertiajs/inertia'; - -import {TaskDetailed, ValidationErrors} from 'app/types'; -import LoggedIn from 'app/layouts/loggedIn'; -import Checkbox from 'app/components/checkbox'; -import DueOn from 'app/components/dueOn'; -import Modal from 'app/components/modal'; -import TaskQuickForm from 'app/components/taskQuickForm'; -import TaskNotes from 'app/components/taskNotes'; -import TaskSubtasks from 'app/components/taskSubtasks'; -import ProjectBadge from 'app/components/projectBadge'; -import useKeyboardShortcut from 'app/hooks/useKeyboardShortcut'; - -type Props = { - task: TaskDetailed; - referer: string; -}; - -export default function TasksView({referer, task}: Props): JSX.Element { - const [editing, setEditing] = useState(false); - const [errors, setErrors] = useState({}); - - useKeyboardShortcut(['e'], () => { - setEditing(true); - }); - - function handleClose() { - const target = referer === window.location.pathname ? '/tasks/upcoming' : referer; - Inertia.visit(target); - } - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const formData = new FormData(event.target as HTMLFormElement); - const promise = new Promise((resolve, reject) => { - // Do an XHR request so we can handle validation errors - // inside the modal. - axios - .post(`/tasks/${task.id}/edit`, formData) - .then(() => { - setEditing(false); - resolve(true); - Inertia.reload({only: ['task']}); - }) - .catch(error => { - const errors: ValidationErrors = {}; - if (error.response) { - setErrors(error.response.data.errors); - } - reject(errors); - }); - }); - - return promise; - } - - function handleCancel() { - setEditing(false); - } - - return ( - - -
    - {editing ? ( - - ) : ( - setEditing(true)} /> - )} - - -
    -
    -
    - ); -} - -type SummaryProps = { - task: TaskDetailed; - onClick: () => void; -}; - -function TaskSummary({task, onClick}: SummaryProps) { - function handleComplete(e: React.ChangeEvent) { - e.stopPropagation(); - Inertia.post( - `/tasks/${task.id}/${task.completed ? 'incomplete' : 'complete'}`, - {}, - {only: ['task']} - ); - } - - return ( -
    -
    - -

    - {task.title} -

    -
    - - - - -
    - ); -} diff --git a/assets/js/Pages/Users/Edit.tsx b/assets/js/Pages/Users/Edit.tsx deleted file mode 100644 index 044007b3..00000000 --- a/assets/js/Pages/Users/Edit.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; -import Select from 'react-select'; - -import {VALID_THEMES} from 'app/constants'; -import Modal from 'app/components/modal'; -import {User, ValidationErrors} from 'app/types'; -import {t} from 'app/locale'; -import FormControl from 'app/components/formControl'; -import LoggedIn from 'app/layouts/loggedIn'; - -type Props = { - identity: User; - errors: ValidationErrors; - referer: string; -}; - -export default function Edit({identity, referer, errors}: Props) { - function handleClose() { - Inertia.visit(referer); - } - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const formData = new FormData(event.target as HTMLFormElement); - Inertia.post('/users/profile', formData); - } - const title = t('Edit Profile'); - - return ( - - -

    {t('Edit Profile')}

    -
    - - - - - ( - - - - -
    - - -
    - -
    -
    - ); -} diff --git a/assets/js/actions/calendars.tsx b/assets/js/actions/calendars.tsx deleted file mode 100644 index 8060320f..00000000 --- a/assets/js/actions/calendars.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; - -import {confirm} from 'app/components/confirm'; -import {CalendarProvider, CalendarSource} from 'app/types'; - -export async function deleteProvider(provider: CalendarProvider) { - if ( - await confirm('Are you sure?', 'This will delete all synced calendars and events.') - ) { - return Inertia.post(`/calendars/${provider.id}/delete`); - } -} - -export async function deleteSource(provider: CalendarProvider, source: CalendarSource) { - if ( - await confirm('Are you sure?', 'This will stop automatic updates for this calendar.') - ) { - return Inertia.post(`/calendars/${provider.id}/sources/${source.id}/delete`); - } -} diff --git a/assets/js/actions/projects.tsx b/assets/js/actions/projects.tsx deleted file mode 100644 index b8f020e0..00000000 --- a/assets/js/actions/projects.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; - -import {TaskProject, ProjectSection} from 'app/types'; -import {confirm} from 'app/components/confirm'; - -export function archiveProject(project: TaskProject) { - return Inertia.post(`/projects/${project.slug}/archive`); -} - -export function unarchiveProject(project: TaskProject) { - return Inertia.post(`/projects/${project.slug}/unarchive`); -} - -export async function deleteProject(project: TaskProject) { - if ( - await confirm( - 'Are you sure?', - 'This will delete all the tasks in this project as well.' - ) - ) { - return Inertia.post(`/projects/${project.slug}/delete`); - } -} - -export async function deleteSection(project: TaskProject, section: ProjectSection) { - if ( - await confirm( - 'Are you sure?', - 'All tasks will be moved out of the section, but remain in the project.' - ) - ) { - return Inertia.post(`/projects/${project.slug}/sections/${section.id}/delete`); - } -} diff --git a/assets/js/actions/tasks.tsx b/assets/js/actions/tasks.tsx deleted file mode 100644 index 61c8409a..00000000 --- a/assets/js/actions/tasks.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import axios, {AxiosResponse} from 'axios'; -import {Inertia} from '@inertiajs/inertia'; - -import {NEW_ID} from 'app/constants'; -import {DefaultTaskValues, Task, TaskDetailed} from 'app/types'; -import {UpdaterCallback, UpdateData} from 'app/components/taskGroupedSorter'; - -export function createTask(data: FormData | Task): Promise { - const promise = new Promise((resolve, reject) => { - Inertia.post('/tasks/add', data, { - onSuccess: () => { - resolve(true); - }, - onError: errors => { - reject(errors); - }, - preserveScroll: true, - }); - }); - - return promise; -} - -export function makeTaskFromDefaults( - defaults: DefaultTaskValues | undefined -): TaskDetailed { - const task: TaskDetailed = { - id: NEW_ID, - section_id: null, - title: '', - body: '', - due_on: null, - completed: false, - evening: false, - day_order: 0, - child_order: 0, - created: '', - modified: '', - complete_subtask_count: 0, - subtask_count: 0, - project: { - id: defaults?.project_id ? Number(defaults.project_id) : 0, - name: '', - slug: '', - color: 0, - }, - subtasks: [], - ...defaults, - }; - if (task.due_on === undefined) { - task.due_on = null; - } - - return task; -} - -export function updateTask( - task: Task, - data: FormData | Partial> -): Promise> { - return axios.post(`/tasks/${task.id}/edit`, data); -} - -export const sortUpdater: UpdaterCallback = ( - task: Task, - newIndex: number, - destinationKey: string -): UpdateData => { - const data: UpdateData = { - day_order: newIndex, - }; - - let isEvening = false; - let newDate = destinationKey; - if (newDate.includes('evening:')) { - isEvening = true; - newDate = newDate.substring(8); - } - if (isEvening !== task.evening || isEvening) { - data.evening = isEvening; - } - if (newDate !== task.due_on) { - data.due_on = newDate; - } - return data; -}; diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 728b92f3..fb21bc7e 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -1,35 +1,26 @@ import 'vite/modulepreload-polyfill'; -import axios from 'axios'; -import {InertiaApp} from '@inertiajs/inertia-react'; -import {render} from 'react-dom'; import '../sass/app.scss'; // Htmx setup import htmx from 'htmx.org'; -import 'app/extensions/ajax'; -import 'app/extensions/flashMessage'; // Expose htmx on window // @ts-ignore-next-line window.htmx = htmx; -// Setup CSRF tokens. -axios.defaults.xsrfCookieName = 'csrfToken'; -axios.defaults.xsrfHeaderName = 'X-Csrf-Token'; - -const el = document.getElementById('app'); -if (!el) { - throw new Error('Could not find application root element'); -} +// htmx extensions +import 'app/extensions/ajax'; +import 'app/extensions/flashMessage'; +import 'app/extensions/projectSorter'; +import 'app/extensions/taskSorter'; +import 'app/extensions/sectionSorter'; +import 'app/extensions/subtaskSorter'; +import 'app/extensions/removeRow'; -render( - { - const pages = import.meta.glob(`./Pages/*/*.tsx`); - return (await pages[`./Pages/${name}.tsx`]()).default; - }} - />, - el -); +// Webcomponents +import 'app/webcomponents/dropDown.ts'; +import 'app/webcomponents/modalWindow.ts'; +import 'app/webcomponents/selectBox.ts'; +import 'app/webcomponents/dueOn.ts'; +import 'app/webcomponents/markdownText.ts'; diff --git a/assets/js/components/addTaskButton.tsx b/assets/js/components/addTaskButton.tsx deleted file mode 100644 index 864b55a7..00000000 --- a/assets/js/components/addTaskButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import {useContext, useRef} from 'react'; -import {t} from 'app/locale'; -import {DefaultTaskValues} from 'app/types'; - -import {DefaultTaskValuesContext} from 'app/providers/defaultTaskValues'; -import {InlineIcon} from './icon'; -import Tooltip from './tooltip'; - -type Props = { - defaultValues: DefaultTaskValues; -}; - -function AddTaskButton({defaultValues}: Props) { - const ref = useRef(null); - const [_, updateDefaultValues] = useContext(DefaultTaskValuesContext); - - function handleClick(e: React.MouseEvent) { - e.preventDefault(); - updateDefaultValues({ - type: 'reset', - data: defaultValues, - }); - if (!ref.current) { - return; - } - - // trigger the keyboard shortcut. - const eventOptions = { - key: 'c', - bubbles: true, - cancelable: true, - }; - let event = new KeyboardEvent('keydown', eventOptions); - ref.current.dispatchEvent(event); - } - - return ( - - - - ); -} - -export default AddTaskButton; diff --git a/assets/js/components/calendarItemList.tsx b/assets/js/components/calendarItemList.tsx deleted file mode 100644 index 092ad680..00000000 --- a/assets/js/components/calendarItemList.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import classnames from 'classnames'; -import type {ReactNode, CSSProperties} from 'react'; - -import {CalendarItem} from 'app/types'; -import {toDateString, toTimeString} from 'app/utils/dates'; -import {PROJECT_COLORS} from 'app/constants'; - -type Props = { - items: CalendarItem[]; - date: string; -}; - -function CalendarItemList({date, items}: Props) { - return ( -
    - {items.map(item => ( - - ))} -
    - ); -} - -type ItemProps = { - item: CalendarItem; - date: string; -}; - -function CalendarListItem({date, item}: ItemProps) { - let start: ReactNode = ''; - let allDay = item.all_day; - if (!item.all_day) { - const startTime = new Date(item.start_time); - if (toDateString(startTime) === date) { - start = ; - } else { - allDay = true; - } - } - const classname = classnames('calendar-item', { - 'all-day': allDay, - }); - - const style = {'--calendar-color': PROJECT_COLORS[item.color].code} as CSSProperties; - return ( -
    - {start} - - {item.title} - -
    - ); -} - -export default CalendarItemList; diff --git a/assets/js/components/checkbox.tsx b/assets/js/components/checkbox.tsx deleted file mode 100644 index b1a60101..00000000 --- a/assets/js/components/checkbox.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import {InlineIcon} from './icon'; - -type Props = { - name: string; - checked?: boolean; - value?: string | number; - onChange?: (event: React.ChangeEvent) => void; -}; - -function Checkbox({name, checked, onChange, value = '1'}: Props): JSX.Element { - const uniqId = Math.round(Math.random() * 500); - const fullId = `checkbox-${uniqId}-${name}`; - return ( - - ); -} - -export default Checkbox; diff --git a/assets/js/components/colorSelect.tsx b/assets/js/components/colorSelect.tsx deleted file mode 100644 index eaf5d0c7..00000000 --- a/assets/js/components/colorSelect.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Fragment } from 'react'; -import classnames from 'classnames'; -import Select, {ValueType, OptionProps, SingleValueProps} from 'react-select'; -import {PROJECT_COLORS} from 'app/constants'; -import {InlineIcon} from 'app/components/icon'; -import {t} from 'app/locale'; - -type ColorItem = { - id: number; - name: string; - code: string; -}; - -type ColorProps = { - name: string; - color: string; -}; - -function Color({name, color}: ColorProps) { - return ( - - - {name} - - ); -} - -type Props = { - /** - * Default value. - */ - value?: number; - /** - * Display labels or not - */ - hideLabel?: boolean; - /** - * Respond to values changing. - */ - onChange?: (value: number | string) => void; -}; - -function ColorSelect({hideLabel, value, onChange}: Props): JSX.Element { - const selected = value !== undefined ? value : PROJECT_COLORS[0].id; - const valueOption = PROJECT_COLORS.find(opt => opt.id === selected); - - function handleChange(selected: ValueType) { - if (selected && onChange) { - onChange(selected.id); - } - } - - function ColorOption(props: OptionProps) { - const {innerRef, innerProps, data} = props; - const name = hideLabel ? '' : data.name; - const className = classnames({ - 'is-selected': props.isSelected, - 'is-focused': props.isFocused, - }); - return ( -
    - -
    - ); - } - - function ColorValue(props: SingleValueProps) { - const {innerProps, data} = props; - const name = hideLabel ? '' : data.name; - return ( -
    - -
    - ); - } - const classPrefix = hideLabel ? 'select-narrow' : 'select'; - return ( - - - {!isToday && ( - - {t('Today')} - - )} - {!isThisEvening && ( - - {t('This Evening')} - - )} - {!isTomorrow && ( - - - {t('Tommorrow')} - - )} - {isWeekend && monday && ( - // If today is saturday or sunday, go to monday - - - {t('On Monday')} - - )} - {isFriday && monday && ( - // If today is friday, monday is 3 days later. - - - {t('On Monday')} - - )} - {futureDue && isEvening && ( - - - {t('{date} day', {date: formatCompactDate(task.due_on ?? '')})} - - )} - {futureDue && !isEvening && ( - - - {t('{date} evening', {date: formatCompactDate(task.due_on ?? '')})} - - )} - - - {t('Later')} - - onChange(toDateString(value), task.evening)} - modifiers={daypickerModifiers} - fromMonth={todayDate} - selectedDays={dueOn} - todayButton={t('Today')} - pagedNavigation - numberOfMonths={2} - /> -
    - - - {t('Evening')} - - } - /> -
    - - ); -} diff --git a/assets/js/components/flashMessages.tsx b/assets/js/components/flashMessages.tsx deleted file mode 100644 index cdebfade..00000000 --- a/assets/js/components/flashMessages.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useState, useRef } from 'react'; -import classnames from 'classnames'; - -import {FlashMessage} from 'app/types'; -import {InlineIcon} from 'app/components/icon'; - -type Props = { - flash: FlashMessage | null; -}; - -const TIMEOUT = 5000; - -export default function FlashMessages({flash}: Props) { - if (!flash || !flash.message) { - return null; - } - let mounted = true; - const hideTimer = useRef(undefined); - const showTimer = useRef(undefined); - const [showing, setShowing] = useState(false); - const [hovering, setHovering] = useState(false); - - // Set a hide delay when hovering or showing is changed. - useEffect( - function () { - if (hovering || !showing || !mounted) { - return; - } - hideTimer.current = window.setTimeout(function () { - setShowing(false); - }, TIMEOUT); - return function cleanup() { - clearTimeout(hideTimer.current); - }; - }, - [hovering, showing] - ); - - // Toggle state on mount to animate in. - useEffect( - function () { - showTimer.current = window.setTimeout(function () { - if (mounted) { - setShowing(true); - } - }, 0); - return function cleanup() { - mounted = false; - clearTimeout(showTimer.current); - }; - }, - [flash] - ); - - const className = classnames('flash-message', flash.element); - - let icon: React.ReactNode = null; - if (flash.element === 'flash-success') { - icon = ; - } else if (flash.element === 'flash-error') { - icon = ; - } - - function handleMouseEnter() { - window.clearTimeout(showTimer.current); - window.clearTimeout(hideTimer.current); - if (mounted) { - setHovering(true); - } - } - - return ( -
    setHovering(false)} - > - {icon} - {flash.message} -
    - ); -} diff --git a/assets/js/components/formControl.tsx b/assets/js/components/formControl.tsx deleted file mode 100644 index 4c24c2b0..00000000 --- a/assets/js/components/formControl.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import classnames from 'classnames'; - -import {ValidationErrors} from 'app/types'; -import FormError from 'app/components/formError'; - -type InputAttrs = Pick; - -type InputType = 'text' | 'email' | 'password' | ((attrs: InputAttrs) => React.ReactNode); - -type Props = { - name: string; - label: React.ReactNode; - type: InputType; - className?: string; - placeholder?: string; - value?: string | number; - id?: string; - required?: boolean; - help?: React.ReactNode; - errors?: ValidationErrors; -}; - -function FormControl({ - className, - errors, - help, - id, - label, - name, - placeholder, - required, - type, - value, -}: Props): JSX.Element { - id = id ?? name; - - let input: React.ReactNode; - if (typeof type === 'string') { - input = ( - - ); - } else if (typeof type === 'function') { - const inputAttrs = {name, id, required}; - input = type(inputAttrs); - } - className = classnames('form-control', className, { - 'is-error': errors && errors[name] !== undefined, - }); - - return ( -
    -
    - - {help &&

    {help}

    } -
    -
    - {input} - -
    -
    - ); -} - -export default FormControl; diff --git a/assets/js/components/formError.tsx b/assets/js/components/formError.tsx deleted file mode 100644 index 76d125ee..00000000 --- a/assets/js/components/formError.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ValidationErrors} from 'app/types'; - -type Props = { - errors: ValidationErrors | null | undefined; - field: string; -}; - -export default function FormError({errors, field}: Props) { - if (!errors || !errors.hasOwnProperty(field)) { - return null; - } - return
    {errors[field]}
    ; -} diff --git a/assets/js/components/globalTaskCreate.tsx b/assets/js/components/globalTaskCreate.tsx deleted file mode 100644 index 57291ab4..00000000 --- a/assets/js/components/globalTaskCreate.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import classnames from 'classnames'; -import {Fragment, useContext, useState} from 'react'; - -import {createTask, makeTaskFromDefaults} from 'app/actions/tasks'; -import useKeyboardShortcut from 'app/hooks/useKeyboardShortcut'; -import {t} from 'app/locale'; -import {DefaultTaskValuesContext} from 'app/providers/defaultTaskValues'; -import {ValidationErrors} from 'app/types'; - -import Modal from './modal'; -import TaskQuickForm from './taskQuickForm'; -import {Icon} from './icon'; - -type Props = {}; - -function GlobalTaskCreate(_props: Props) { - const [defaultTaskValues, _] = useContext(DefaultTaskValuesContext); - const [visible, setVisible] = useState(false); - const [errors, setErrors] = useState({}); - const showForm = () => setVisible(true); - - useKeyboardShortcut(['c'], showForm); - const classname = classnames('button-global-add', { - 'button-primary': !visible, - 'button-muted': visible, - }); - - const button = ( - - ); - - if (!visible) { - return button; - } - const task = makeTaskFromDefaults(defaultTaskValues); - - function handleClose(event: MouseEvent | React.MouseEvent) { - if (event.defaultPrevented) { - return; - } - setVisible(false); - } - - function handleCancel() { - setVisible(false); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - e.persist(); - - const form = e.target as HTMLFormElement; - const formData = new FormData(form); - const promise = createTask(formData); - - return promise.catch((errors: ValidationErrors) => { - setErrors(errors); - - return promise; - }); - } - - return ( - - {button} - -

    {t('Create a new Task')}

    - -
    -
    - ); -} - -export default GlobalTaskCreate; diff --git a/assets/js/components/helpModal.tsx b/assets/js/components/helpModal.tsx deleted file mode 100644 index 89ef2603..00000000 --- a/assets/js/components/helpModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import Modal from './modal'; - -type Props = { - onClose: () => void; -}; - -function HelpModal({onClose}: Props) { - return ( - -

    Help and Shortcuts

    -

    - We all need a helping hand sometimes. Hopefully this makes your day a bit easier. -

    -

    Keyboard shortcuts

    -

    Anywhere

    -
    -
    - c -
    -
    - Create a new task. The task will use the current page state for default values. -
    -
    - t -
    -
    Go to "Today"
    -
    - u -
    -
    Go to "Upcoming"
    -
    - ? -
    -
    This help screen
    -
    - -

    Views with Task Lists

    -
    -
    - j -
    -
    Move task selection down
    -
    - k -
    -
    Move task selection up
    -
    - -

    With a task selected in a Task List

    -
    -
    - d -
    -
    Mark task complete
    -
    - o -
    -
    View task details
    -
    - -

    Task Details

    -
    -
    - e -
    -
    Edit task details
    -
    - n -
    -
    Edit task notes
    -
    - a -
    -
    Add a subtask
    -
    - -

    Task Name Input

    -
    -
    - # -
    -
    Autocomplete to select project.
    -
    - % -
    -
    Autocomplete to select date.
    -
    - & -
    -
    Autocomplete to select date with evening.
    -
    -
    - ); -} - -export default HelpModal; diff --git a/assets/js/components/icon.tsx b/assets/js/components/icon.tsx deleted file mode 100644 index 20a790a1..00000000 --- a/assets/js/components/icon.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {Icon, InlineIcon, IconProps, addIcon} from '@iconify/react'; - -// Pull in the icons that are being used to help -// with treeshaking -import alert16 from '@iconify/icons-octicon/alert-16'; -import archive16 from '@iconify/icons-octicon/archive-16'; -import calendar16 from '@iconify/icons-octicon/calendar-16'; -import check16 from '@iconify/icons-octicon/check-16'; -import checkCircle16 from '@iconify/icons-octicon/check-circle-16'; -import chevronDown16 from '@iconify/icons-octicon/chevron-down-16'; -import circle16 from '@iconify/icons-octicon/circle-16'; -import clippy16 from '@iconify/icons-octicon/clippy-16'; -import clock16 from '@iconify/icons-octicon/clock-16'; -import dotfill16 from '@iconify/icons-octicon/dot-fill-16'; -import gear16 from '@iconify/icons-octicon/gear-16'; -import grabber24 from '@iconify/icons-octicon/grabber-24'; -import kebab16 from '@iconify/icons-octicon/kebab-horizontal-16'; -import lock16 from '@iconify/icons-octicon/lock-16'; -import moon16 from '@iconify/icons-octicon/moon-16'; -import note16 from '@iconify/icons-octicon/note-16'; -import pencil16 from '@iconify/icons-octicon/pencil-16'; -import plus16 from '@iconify/icons-octicon/plus-16'; -import pluscircle16 from '@iconify/icons-octicon/plus-circle-16'; -import sun16 from '@iconify/icons-octicon/sun-16'; -import sync16 from '@iconify/icons-octicon/sync-16'; -import trash16 from '@iconify/icons-octicon/trashcan-16'; -import workflow16 from '@iconify/icons-octicon/workflow-16'; - -addIcon('alert', alert16); -addIcon('archive', archive16); -addIcon('calendar', calendar16); -addIcon('check', check16); -addIcon('checkcircle', checkCircle16); -addIcon('chevrondown', chevronDown16); -addIcon('clock', clock16); -addIcon('circle', circle16); -addIcon('clippy', clippy16); -addIcon('dot', dotfill16); -addIcon('gear', gear16); -addIcon('grabber', grabber24); -addIcon('lock', lock16); -addIcon('moon', moon16); -addIcon('note', note16); -addIcon('pencil', pencil16); -addIcon('plus', plus16); -addIcon('pluscircle', pluscircle16); -addIcon('kebab', kebab16); -addIcon('sun', sun16); -addIcon('sync', sync16); -addIcon('trash', trash16); -addIcon('workflow', workflow16); - -type WrappedProps = {width?: string} & IconProps; - -function getWidth(size: string): number { - switch (size) { - case 'xsmall': - return 12; - case 'small': - return 14; - case 'normal': - return 16; - case 'medium': - return 18; - case 'large': - return 20; - case 'xlarge': - return 24; - default: - return parseInt(size, 10); - } -} - -function WrappedIcon({width = 'normal', ...props}: WrappedProps) { - return ; -} - -function WrappedInlineIcon({width = 'normal', ...props}: WrappedProps) { - return ; -} - -export {WrappedIcon as Icon, WrappedInlineIcon as InlineIcon}; diff --git a/assets/js/components/markdownText.tsx b/assets/js/components/markdownText.tsx deleted file mode 100644 index 06418a8d..00000000 --- a/assets/js/components/markdownText.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import DOMpurify from 'dompurify'; -import marked from 'marked'; - -type Props = { - text: null | string; -}; -function MarkdownText({text}: Props): JSX.Element { - let contents = ''; - if (text) { - const html = marked(text); - contents = DOMpurify.sanitize(html, {USE_PROFILES: {html: true}}); - } - - return
    ; -} - -export default MarkdownText; diff --git a/assets/js/components/modal.tsx b/assets/js/components/modal.tsx deleted file mode 100644 index e87a7513..00000000 --- a/assets/js/components/modal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import {useState} from 'react'; - -import BaseModal from '@reach/dialog'; - -type Props = { - children: React.ReactNode; - onClose: (event: React.MouseEvent | MouseEvent) => void; - label?: string; - className?: string; - canClose?: boolean; - isOpen?: boolean; -}; - -function Modal({ - children, - onClose, - className, - label, - isOpen = true, - canClose = true, -}: Props): JSX.Element { - const [showDialog, setShowDialog] = useState(isOpen); - - function handleClose(event: React.MouseEvent) { - onClose(event); - if (event.isDefaultPrevented()) { - return; - } - event.preventDefault(); - setShowDialog(false); - } - - function handleDismiss() { - const event = new MouseEvent('keyDown', { - view: window, - bubbles: true, - cancelable: true, - buttons: 1, - }); - onClose(event); - } - - return ( - - {canClose && ( - - )} -
    {children}
    -
    - ); -} - -export default Modal; diff --git a/assets/js/components/navLink.tsx b/assets/js/components/navLink.tsx deleted file mode 100644 index 08b23728..00000000 --- a/assets/js/components/navLink.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {InertiaLink, usePage} from '@inertiajs/inertia-react'; -import classnames from 'classnames'; - -type LinkProps = React.ComponentProps; - -type Props = React.PropsWithChildren<{ - href: LinkProps['href']; - className?: string; -}>; - -function NavLink({children, className, href}: Props) { - const page = usePage(); - className = classnames(className ?? '', { - active: page.url.indexOf(href) > 0, - }); - - return ( - - {children} - - ); -} -export default NavLink; diff --git a/assets/js/components/noProjects.tsx b/assets/js/components/noProjects.tsx deleted file mode 100644 index 92f3aa82..00000000 --- a/assets/js/components/noProjects.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import {InertiaLink} from '@inertiajs/inertia-react'; - -import {t} from 'app/locale'; - -function NoProjects(): JSX.Element { - return ( -
    -

    {t('You have no projects')}

    -

    {t('Projects help organize your tasks')}

    -
    - - {t('Create a Project')} - -
    -
    - ); -} - -export default NoProjects; diff --git a/assets/js/components/overflowActionBar.tsx b/assets/js/components/overflowActionBar.tsx deleted file mode 100644 index d28ad699..00000000 --- a/assets/js/components/overflowActionBar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import {ReactNode, useEffect, useState} from 'react'; -import {MenuItem} from '@reach/menu-button'; - -import ContextMenu from './contextMenu'; - -type ActionItem = { - /** - * Label text or element. - */ - label: ReactNode; - /** - * Fired when the menu item/button is clicked. - */ - onSelect: () => void; - /** - * Optional icon for the item. - * Combine with className to color icons. - */ - icon?: ReactNode; - buttonClass?: string; - menuItemClass?: string; - dataTestId?: string; - disabled?: boolean; -}; - -type Props = { - /** - * The items to render. - */ - items: ActionItem[]; - /** - * The window width in px that this menu should become - * a ContextMenu - */ - foldWidth: Number; - /** - * The tooltip for the collapsed menu. - */ - label?: string; -}; - -/** - * A container for buttons that when `foldWidth` - * is reached will collapse into a context menu. - */ -function OverflowActionBar({items, foldWidth, label}: Props) { - const [isCompact, setCompact] = useState(false); - - // Toggle state based on media query listeners. - // Start listeners on mount and remove during unmount. - useEffect(() => { - const query = window.matchMedia(`(max-width: ${foldWidth}px)`); - const handler = () => setCompact(query.matches ? true : false); - query.addEventListener('change', handler); - - if (query.matches) { - setCompact(true); - } - - // cleanup - return () => { - query.removeEventListener('change', handler); - }; - }, []); - - function handleClick(item: ActionItem) { - return function (event: React.MouseEvent) { - event.stopPropagation(); - item.onSelect(); - }; - } - - // Render context menu. - if (isCompact) { - return ( - - {items.map((item, index) => ( - - {item.icon} - {item.label} - - ))} - - ); - } - - // Render button bar. - return ( -
    - {items.map((item, index) => ( - - ))} -
    - ); -} - -export default OverflowActionBar; diff --git a/assets/js/components/profileMenu.tsx b/assets/js/components/profileMenu.tsx deleted file mode 100644 index 0fbf1b78..00000000 --- a/assets/js/components/profileMenu.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {MenuLink, MenuButton} from '@reach/menu-button'; -import {usePage, InertiaLink} from '@inertiajs/inertia-react'; - -import {t} from 'app/locale'; -import {User} from 'app/types'; -import {InlineIcon} from 'app/components/icon'; -import DropdownMenu from 'app/components/dropdownMenu'; - -type SharedProps = { - identity: User; -}; - -export default function ProfileMenu(): JSX.Element { - const {identity} = usePage().props as SharedProps; - const avatarUrl = `https://www.gravatar.com/avatar/${identity.avatar_hash}?s=50&default=retro`; - return ( -
    - ( - - - - )} - > -
    {identity.name}
    -
    - - - {t('Edit Profile')} - - - - {t('Calendars')} - - - - {t('Update Password')} - -
    - - {t('Logout')} - - -
    - ); -} diff --git a/assets/js/components/projectBadge.tsx b/assets/js/components/projectBadge.tsx deleted file mode 100644 index be2a40b3..00000000 --- a/assets/js/components/projectBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import {InlineIcon} from 'app/components/icon'; -import {TaskProject} from 'app/types'; -import {PROJECT_COLORS} from 'app/constants'; - -type Props = { - project: TaskProject; -}; - -function ProjectBadge({project}: Props): JSX.Element { - const color = PROJECT_COLORS[project.color].code ?? PROJECT_COLORS[0].code; - return ( - - - {project.name} - - ); -} - -export default ProjectBadge; diff --git a/assets/js/components/projectFilter.tsx b/assets/js/components/projectFilter.tsx deleted file mode 100644 index b167eaef..00000000 --- a/assets/js/components/projectFilter.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import {t} from 'app/locale'; -import ProjectSorter from 'app/components/projectSorter'; -import NavLink from './navLink'; -import {InlineIcon} from './icon'; - -function ProjectFilter(): JSX.Element { - return ( -
    -
      -
    • - - - {t('Today')} - -
    • -
    • - - - {t('Upcoming')} - -
    • -
    -

    {t('Projects')}

    - -
    - - - {t('New Project')} - - - - {t('Archived Projects')} - - - - {t('Trash Bin')} - -
    -
    - ); -} - -export default ProjectFilter; diff --git a/assets/js/components/projectItem.tsx b/assets/js/components/projectItem.tsx deleted file mode 100644 index 0d86e39b..00000000 --- a/assets/js/components/projectItem.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useState } from 'react'; -import {InertiaLink, usePage} from '@inertiajs/inertia-react'; -import classnames from 'classnames'; - -import {Project} from 'app/types'; -import ProjectBadge from './projectBadge'; -import ProjectMenu from './projectMenu'; - -type Props = { - project: Project; -}; - -export default function ProjectItem({project}: Props): JSX.Element { - const path = `/projects/${project.slug}`; - const [active, setActive] = useState(false); - const page = usePage(); - const className = classnames('project-item', { - active: page.url.indexOf(path) > 0, - }); - - return ( -
    setActive(true)} - onMouseLeave={() => setActive(false)} - > - - - {project.incomplete_task_count.toLocaleString()} - - setActive(!active)} /> -
    - ); -} diff --git a/assets/js/components/projectMenu.tsx b/assets/js/components/projectMenu.tsx deleted file mode 100644 index dc22de56..00000000 --- a/assets/js/components/projectMenu.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import {MenuItem, MenuLink} from '@reach/menu-button'; -import {InertiaLink} from '@inertiajs/inertia-react'; - -import {Project} from 'app/types'; -import {t} from 'app/locale'; -import {archiveProject, deleteProject, unarchiveProject} from 'app/actions/projects'; -import ContextMenu from 'app/components/contextMenu'; -import {InlineIcon} from './icon'; -import {useProjects} from 'app/providers/projects'; - -type Props = { - project: Project; - showDetailed?: boolean; - onAddSection?: () => void; - onClick?: (event: React.MouseEvent) => void; -}; - -export default function ProjectMenu({ - project, - onClick, - onAddSection, - showDetailed = false, -}: Props): JSX.Element { - const [_, setProjects] = useProjects(); - - async function handleDelete() { - await deleteProject(project); - setProjects(null); - } - async function handleUnarchive() { - await unarchiveProject(project); - setProjects(null); - } - async function handleArchive() { - await archiveProject(project); - setProjects(null); - } - - return ( - - - - {t('Edit Project')} - - {showDetailed && onAddSection && ( - - - {t('Add section')} - - )} - {showDetailed && ( - - - {t('View completed tasks')} - - )} -
    - {project.archived ? ( - - - {t('Unarchive Project')} - - ) : ( - - - {t('Archive Project')} - - )} - - - {t('Delete Project')} - - - ); -} diff --git a/assets/js/components/projectRenameForm.tsx b/assets/js/components/projectRenameForm.tsx deleted file mode 100644 index b5a4cf8f..00000000 --- a/assets/js/components/projectRenameForm.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {Inertia} from '@inertiajs/inertia'; - -import {Project} from 'app/types'; -import {t} from 'app/locale'; - -type Props = { - project: Project; - onCancel: () => void; -}; - -function ProjectRenameForm({onCancel, project}: Props): JSX.Element { - const url = `/projects/${project.slug}/edit`; - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const form = event.target as HTMLFormElement; - const formData = new FormData(form); - Inertia.post(url, formData, { - onSuccess: () => onCancel(), - }); - } - - function handleKeyDown(e: React.KeyboardEvent) { - switch (e.key) { - case 'Esc': - case 'Escape': - onCancel(); - e.stopPropagation(); - break; - } - } - - return ( -
    -
    - -
    -
    - - -
    -
    - ); -} - -export default ProjectRenameForm; diff --git a/assets/js/components/projectSectionSorter.tsx b/assets/js/components/projectSectionSorter.tsx deleted file mode 100644 index 4e7062c5..00000000 --- a/assets/js/components/projectSectionSorter.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { useState } from 'react'; -import {Inertia} from '@inertiajs/inertia'; -import { - DndContext, - closestCorners, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, - DragOverEvent, - DragStartEvent, -} from '@dnd-kit/core'; -import { - arrayMove, - sortableKeyboardCoordinates, - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; - -import {Project, ProjectSection, Task} from 'app/types'; -import {insertAtIndex} from 'app/utils/array'; - -const ROOT = '_root_'; - -type GroupedItem = { - /** - * The id for the group. Has an s: prefix - * to make separating sections and tasks in drag over/end - * easier as numeric ids can easily overlap. - */ - key: string; - /** - * The section. Useful for rendering. - * Can be undefined for the root group. - */ - section?: ProjectSection; - /** - * Tasks in the section - */ - tasks: Task[]; -}; -type GroupedItems = GroupedItem[]; - -type ChildProps = { - groups: GroupedItems; - activeTask?: Task; - activeSection?: ProjectSection; -}; - -type Props = { - project: Project; - tasks: Task[]; - children: (args: ChildProps) => React.ReactNode; -}; - -type UpdateData = { - child_order: number; - section_id?: null | number; -}; - -function createGroups(sections: ProjectSection[], tasks: Task[]): GroupedItems { - const sectionTable = tasks.reduce>((acc, task) => { - const sectionId = task.section_id === null ? ROOT : String(task.section_id); - if (acc[sectionId] === undefined) { - acc[sectionId] = []; - } - acc[sectionId].push(task); - return acc; - }, {}); - - return [ - { - key: ROOT, - section: undefined, - tasks: sectionTable[ROOT] ?? [], - }, - ...sections.map(section => { - return { - key: `s:${section.id}`, - section, - tasks: sectionTable[section.id] ?? [], - }; - }), - ]; -} - -/** - * Find a group by its group key or task id. - * - * Ids can be either section ids (prefixed with s:) or task ids. - */ -function findGroupIndex(groups: GroupedItems, id: string): number { - const sectionIndex = groups.findIndex(group => group.key === id); - if (sectionIndex !== -1) { - return sectionIndex; - } - const taskId = Number(id); - return groups.findIndex( - group => group.tasks.findIndex(task => task.id === taskId) !== -1 - ); -} - -function ProjectSectionSorter({children, project, tasks}: Props): JSX.Element { - const sections = project.sections; - const [activeTask, setActiveTask] = useState(undefined); - const [activeSection, setActiveSection] = useState( - undefined - ); - - const grouped = createGroups(sections, tasks); - const [sorted, setSorted] = useState(undefined); - const items = sorted || grouped; - - function handleDragStart({active}: DragStartEvent) { - if (active.id[0] === 's') { - setActiveSection(items.find(item => item.key === active.id)?.section); - return; - } - const taskId = Number(active.id); - setActiveTask(tasks.find(task => task.id === taskId)); - } - - function handleDragEnd({active, over}: DragEndEvent) { - setActiveTask(undefined); - setActiveSection(undefined); - - // Dropped on nothing, revert. - if (!over) { - return; - } - - // Dragging a section. - if (active.id[0] === 's') { - const sectionId = active.id.slice(2); - let newIndex = items.findIndex(group => group.key === over.id); - // We don't want anything above the root section. - if (newIndex < 1) { - newIndex = 1; - } - - // Index is -1 because the 0th group is the root one. - const data = { - ranking: newIndex - 1, - }; - Inertia.post(`/projects/${project.slug}/sections/${sectionId}/move`, data, { - preserveScroll: true, - onSuccess() { - setSorted(undefined); - }, - }); - return; - } - - // Dragging a task - const sourceGroupIndex = findGroupIndex(items, active.id); - const destinationGroupIndex = findGroupIndex(items, over.id); - if (sourceGroupIndex === -1 || destinationGroupIndex === -1) { - return; - } - const activeId = Number(active.id); - - // Look for the task in the destination group as it should - // be put here by handleDragOver - const newIndex = items[destinationGroupIndex].tasks.findIndex( - task => task.id === activeId - ); - const sectionId = items[sourceGroupIndex].section?.id; - const data: UpdateData = { - child_order: newIndex, - section_id: sectionId === undefined ? null : sectionId, - }; - Inertia.post(`/tasks/${activeId}/move`, data, { - preserveScroll: true, - onSuccess() { - setSorted(undefined); - }, - }); - } - - function handleDragOver({active, over, draggingRect}: DragOverEvent) { - if (!over) { - return; - } - - // Dragging a section. - if (active.id[0] === 's') { - const oldIndex = items.findIndex(group => group.key === active.id); - const newIndex = items.findIndex(group => group.key === over.id); - - setSorted(arrayMove(items, oldIndex, newIndex)); - return; - } - - // Dragging a task - const activeGroupIndex = findGroupIndex(items, active.id); - const overGroupIndex = findGroupIndex(items, over.id); - if (activeGroupIndex === -1 || overGroupIndex === -1) { - return; - } - const activeId = Number(active.id); - const overId = Number(over.id); - - // Moving within the same group. - if (activeGroupIndex === overGroupIndex) { - const active = items[activeGroupIndex].tasks.find(task => task.id === activeId); - if (!active) { - return; - } - // Get the current over index before moving the task. - const overTaskIndex = items[activeGroupIndex].tasks.findIndex( - task => task.id === overId - ); - const section = { - ...items[activeGroupIndex], - tasks: items[activeGroupIndex].tasks.filter(task => task.id !== activeId), - }; - section.tasks = insertAtIndex(section.tasks, overTaskIndex, active); - - const newItems = [...items]; - newItems[activeGroupIndex] = section; - - setSorted(newItems); - return; - } - - // Moving to a new group. - const activeGroup = items[activeGroupIndex]; - const overGroup = items[overGroupIndex]; - - // Use the id lists to find offsets as using the - // tasks requires another extract. - const activeTaskIndex = activeGroup.tasks.findIndex(task => task.id === activeId); - const overTaskIndex = overGroup.tasks.findIndex(task => task.id === overId); - - const isBelowLastItem = - over && - overTaskIndex === overGroup.tasks.length - 1 && - draggingRect.offsetTop > over.rect.offsetTop + over.rect.height; - - const modifier = isBelowLastItem ? 1 : 0; - const newIndex = - overTaskIndex >= 0 ? overTaskIndex + modifier : overGroup.tasks.length + 1; - - // Remove the active task from its current group. - const newActiveGroup = { - key: activeGroup.key, - section: activeGroup.section, - tasks: activeGroup.tasks.filter(task => task.id !== activeId), - }; - // Splice it into the destination group. - const newOverGroup = { - key: overGroup.key, - section: overGroup.section, - tasks: insertAtIndex(overGroup.tasks, newIndex, activeGroup.tasks[activeTaskIndex]), - }; - - const newItems = [...items]; - newItems[activeGroupIndex] = newActiveGroup; - newItems[overGroupIndex] = newOverGroup; - - // This state update allows the faded out task - // to be placed correctly - setSorted(newItems); - } - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - const sectionids = items.map(group => group.key); - const taskids = items.reduce((acc, group) => { - acc.concat(group.tasks.map(task => String(task.id))); - return acc; - }, []); - - return ( - - - - {children({ - groups: items, - activeTask, - activeSection, - })} - - - - ); -} - -export default ProjectSectionSorter; diff --git a/assets/js/components/projectSelect.tsx b/assets/js/components/projectSelect.tsx deleted file mode 100644 index b0bb015d..00000000 --- a/assets/js/components/projectSelect.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import classnames from 'classnames'; -import Select, {ValueType, OptionProps, SingleValueProps} from 'react-select'; - -import {Project} from 'app/types'; -import ProjectBadge from 'app/components/projectBadge'; -import {t} from 'app/locale'; -import {useProjects} from 'app/providers/projects'; -import usePortal from 'app/hooks/usePortal'; - -type ProjectItem = { - value: number; - label: string; - project: Project; -}; - -type Props = { - value: number | undefined | null; - onChange?: (value: number) => void; -}; - -function ProjectOption(props: OptionProps) { - const {innerRef, innerProps, data} = props; - const className = classnames({ - 'is-selected': props.isSelected, - 'is-focused': props.isFocused, - }); - return ( -
    - -
    - ); -} - -function ProjectValue(props: SingleValueProps) { - const {innerProps, data} = props; - return ( -
    - -
    - ); -} - -function ProjectSelect({value, onChange}: Props): JSX.Element { - const portal = usePortal('project-select-portal'); - - const [projects] = useProjects(); - const options: ProjectItem[] = projects.map(project => ({ - value: project.id, - label: project.name, - project: project, - })); - const valueOption = options.find(opt => opt.value === value) || options[0]; - - function handleChange(selected: ValueType) { - if (selected && onChange) { - onChange(selected.project.id); - } - } - - return ( - - -
    -
    - - -
    - - ); -} - -export default SectionQuickForm; diff --git a/assets/js/components/smartTaskInput.tsx b/assets/js/components/smartTaskInput.tsx deleted file mode 100644 index bb182275..00000000 --- a/assets/js/components/smartTaskInput.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import {useMemo} from 'react'; -import {MentionsInput, Mention} from 'react-mentions'; -import {addDays, isLeapYear} from 'date-fns'; - -import usePortal from 'app/hooks/usePortal'; -import {t} from 'app/locale'; -import {Project} from 'app/types'; -import {getToday, parseDateInput, parseDate, toDateString} from 'app/utils/dates'; - -type Props = { - projects: Project[]; - onChangeDate: (value: string) => void; - onChangeProject: (value: number) => void; - onChangeTitle: (title: string, textTitle: string) => void; - onChangeEvening: (evening: boolean) => void; - value?: string; - inputRef?: React.RefObject; -}; - -type MentionOption = {id: string; display: string}; - -function generateMonth(name: string, end: number): MentionOption[] { - const today = getToday(); - const options: MentionOption[] = []; - for (var i = 1; i <= end; i++) { - const value = `${name} ${i}`; - const time = parseDateInput(value); - options.push({id: toDateString(time ?? today), display: value}); - } - return options; -} - -function generateDateOptions(today: string): MentionOption[] { - const date = parseDate(today); - return [ - {id: 's:' + today, display: t('Today')}, - {id: 's:' + toDateString(addDays(date, 1)), display: t('Tomorrow')}, - {id: 'r:' + toDateString(parseDateInput('Monday') ?? date), display: 'Monday'}, - {id: 'r:' + toDateString(parseDateInput('Tuesday') ?? date), display: 'Tuesday'}, - {id: 'r:' + toDateString(parseDateInput('Wednesday') ?? date), display: 'Wednesday'}, - {id: 'r:' + toDateString(parseDateInput('Thursday') ?? date), display: 'Thursday'}, - {id: 'r:' + toDateString(parseDateInput('Saturday') ?? date), display: 'Saturday'}, - {id: 'r:' + toDateString(parseDateInput('Sunday') ?? date), display: 'Sunday'}, - {id: 'r:' + toDateString(parseDateInput('Friday') ?? date), display: 'Friday'}, - ...generateMonth('January', 31), - ...generateMonth('February', isLeapYear(date) ? 29 : 28), - ...generateMonth('March', 31), - ...generateMonth('April', 30), - ...generateMonth('May', 31), - ...generateMonth('June', 30), - ...generateMonth('July', 31), - ...generateMonth('August', 31), - ...generateMonth('September', 30), - ...generateMonth('October', 31), - ...generateMonth('November', 30), - ...generateMonth('December', 31), - ]; -} - -function SmartTaskInput({ - value, - projects, - inputRef, - onChangeDate, - onChangeProject, - onChangeTitle, - onChangeEvening, -}: Props): JSX.Element { - const today = toDateString(getToday()); - const dateOptions = useMemo(() => generateDateOptions(today), [today]); - const portal = usePortal('smart-task-input'); - - function handleChange(_: any, newValue: string) { - const newPlainText = newValue - .replace(/#[^#]+#/, '') - .replace(/%[^%]+%/, '') - .replace(/&[^&]+&/, '') - .trim(); - onChangeTitle(newValue, newPlainText); - } - - return ( - - `#${display}`} - markup="#__display__:__id__#" - onAdd={id => onChangeProject(Number(id))} - data={projects.map(project => ({id: project.id, display: project.name}))} - appendSpaceOnAdd - /> - `%${display}`} - markup="%__display__:__id__%" - onAdd={id => { - onChangeDate(String(id).replace(/[sr]:/, '')); - onChangeEvening(false); - }} - data={dateOptions} - appendSpaceOnAdd - /> - `&${display}`} - markup="&__display__:__id__&" - onAdd={id => { - onChangeDate(String(id).replace(/[sr]:/, '')); - onChangeEvening(true); - }} - data={dateOptions} - appendSpaceOnAdd - /> - - ); -} - -export default SmartTaskInput; diff --git a/assets/js/components/sortableItem.tsx b/assets/js/components/sortableItem.tsx deleted file mode 100644 index ded87ded..00000000 --- a/assets/js/components/sortableItem.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import classnames from 'classnames'; -import {useSortable} from '@dnd-kit/sortable'; -import {CSS} from '@dnd-kit/utilities'; - -import DragHandle from 'app/components/dragHandle'; - -type Props = React.PropsWithChildren<{ - id: string; - dragActive?: string; - tag?: 'li' | 'div'; -}>; -function SortableItem({children, dragActive, focused, id, tag}: Props): JSX.Element { - const {attributes, listeners, setNodeRef, transform, transition} = useSortable({ - id, - }); - const style = { - transform: CSS.Transform.toString(transform), - transition: transition ?? undefined, - }; - const className = classnames('dnd-item', { - 'dnd-ghost': id === dragActive, - }); - - // Can't be bothered to figure out 'union too complex' error - // when using a dynamic tag. - if (tag === 'li') { - return ( -
  • - - {children} -
  • - ); - } - return ( -
    - - {children} -
    - ); -} - -export default SortableItem; diff --git a/assets/js/components/subtaskAddForm.tsx b/assets/js/components/subtaskAddForm.tsx deleted file mode 100644 index 81c4025a..00000000 --- a/assets/js/components/subtaskAddForm.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {useState} from 'react'; -import axios, {AxiosResponse} from 'axios'; - -import {NEW_ID} from 'app/constants'; -import {t} from 'app/locale'; -import {TaskDetailed, Subtask} from 'app/types'; -import {useSubtasks} from 'app/providers/subtasks'; - -type Props = { - task: TaskDetailed; -}; - -export default function SubtaskAddForm({task}: Props) { - const [value, setValue] = useState(''); - const [subtasks, setSubtasks] = useSubtasks(); - - async function handleSubmitSave(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - - // Do an XHR request so we can update page state - // as reloading doesn't work due to sort contexts - try { - const resp: AxiosResponse<{subtask: Subtask}> = await axios.post( - `/tasks/${task.id}/subtasks`, - formData - ); - // Clear input for next task. - setValue(''); - setSubtasks([...subtasks, resp.data.subtask]); - } catch (error) { - // TOOD handle this error. - } - } - - async function handleSubmitAdd(e: React.FormEvent) { - e.preventDefault(); - e.stopPropagation(); - - const newSubtask: Subtask = { - id: NEW_ID, - title: value, - body: '', - completed: false, - }; - // Clear the input for the next task - setValue(''); - setSubtasks([...subtasks, newSubtask]); - } - - function handleKeyDown(e: React.KeyboardEvent) { - switch (e.key) { - case 'Esc': - case 'Escape': - e.stopPropagation(); - break; - } - } - const isNew = task.id == NEW_ID; - - if (isNew) { - return ( -
    - ) => setValue(e.target.value)} - /> -
    - -
    -
    - ); - } - - return ( -
    - ) => setValue(e.target.value)} - onKeyDown={handleKeyDown} - /> -
    - -
    -
    - ); -} diff --git a/assets/js/components/subtaskEditForm.tsx b/assets/js/components/subtaskEditForm.tsx deleted file mode 100644 index d6279229..00000000 --- a/assets/js/components/subtaskEditForm.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import axios, {AxiosResponse} from 'axios'; - -import {t} from 'app/locale'; -import {Subtask} from 'app/types'; -import {useSubtasks} from 'app/providers/subtasks'; - -type Props = { - taskId: number; - index: number; - subtask: Subtask; - onCancel: () => void; -}; - -export default function SubtaskEditForm({subtask, index, taskId, onCancel}: Props) { - const [subtasks, setSubtasks] = useSubtasks(); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const formData = new FormData(event.target as HTMLFormElement); - - // Do an XHR request so we can update page state - // as reloading doesn't work due to sort contexts - try { - const resp: AxiosResponse<{subtask: Subtask}> = await axios.post( - `/tasks/${taskId}/subtasks/${subtask.id}/edit`, - formData - ); - // TODO see if we can reset contexts instead of repeating update logic here. - const updated = [...subtasks]; - updated[index] = resp.data.subtask; - setSubtasks(updated); - onCancel(); - } catch (error) { - // TOOD handle this error. - } - } - return ( -
    - -
    - - -
    -
    - ); -} diff --git a/assets/js/components/subtaskItem.tsx b/assets/js/components/subtaskItem.tsx deleted file mode 100644 index 066563af..00000000 --- a/assets/js/components/subtaskItem.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import {Fragment, useState} from 'react'; -import classnames from 'classnames'; -import {Inertia} from '@inertiajs/inertia'; - -import SubtaskEditForm from 'app/components/subtaskEditForm'; -import Checkbox from 'app/components/checkbox'; -import {Subtask} from 'app/types'; -import {NEW_ID} from 'app/constants'; - -type RowProps = { - taskId: number; - index: number; - subtask: Subtask; -}; - -function SubtaskItem({index, subtask, taskId}: RowProps): JSX.Element { - const [editing, setEditing] = useState(false); - let isNew = subtask.id == NEW_ID; - function handleComplete(event: React.ChangeEvent) { - event.stopPropagation(); - if (isNew) { - return; - } - Inertia.post( - `/tasks/${taskId}/subtasks/${subtask.id}/toggle`, - {}, - { - only: ['task'], - } - ); - } - const className = classnames('subtask-row', { - 'is-completed': subtask.completed, - }); - let inputs: React.ReactNode; - - if (isNew) { - const namePrefix = `subtasks[${index}]`; - inputs = ( - - - - - ); - } - - return ( -
    - - {editing ? ( - setEditing(false)} - /> - ) : ( -
    setEditing(true)}> - {subtask.title} - {inputs} -
    - )} -
    - ); -} -export default SubtaskItem; diff --git a/assets/js/components/subtaskSorter.tsx b/assets/js/components/subtaskSorter.tsx deleted file mode 100644 index 6732ce5c..00000000 --- a/assets/js/components/subtaskSorter.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import {useState} from 'react'; -import {Inertia} from '@inertiajs/inertia'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - DragEndEvent, - DragStartEvent, -} from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - verticalListSortingStrategy, - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable'; - -import {Task, Subtask} from 'app/types'; -import SubtaskItem from 'app/components/subtaskItem'; -import SortableItem from 'app/components/sortableItem'; -import {useSubtasks} from 'app/providers/subtasks'; - -import DragHandle from './dragHandle'; - -type Props = { - task: Task; -}; - -/** - * Abstraction around reorder lists of todo subtasks and optimistically updating state. - */ -export default function SubtaskSorter({task}: Props): JSX.Element { - const [subtasks, setSubtasks] = useSubtasks(); - const subtaskIds = subtasks.map(subtask => String(subtask.id)); - - const [activeSubtask, setActiveSubtask] = useState(null); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - function handleDragStart(event: DragStartEvent) { - const activeId = Number(event.active.id); - setActiveSubtask(subtasks.find(p => p.id === activeId) ?? null); - } - - function handleDragEnd(event: DragEndEvent) { - const {active, over} = event; - setActiveSubtask(null); - - // Dropped outside of a dropzone - if (!over) { - return; - } - const oldIndex = subtaskIds.indexOf(active.id); - const newIndex = subtaskIds.indexOf(over.id); - const newItems = arrayMove(subtasks, oldIndex, newIndex); - - setSubtasks(newItems); - - const data = { - ranking: newIndex, - }; - - Inertia.post(`/tasks/${task.id}/subtasks/${active.id}/move`, data, { - only: ['task', 'flash'], - onSuccess() { - // Revert local state. - setSubtasks(null); - }, - }); - } - - return ( - - -
      - {subtasks.map((subtask, index) => ( - - - - ))} -
    -
    - - {activeSubtask ? ( -
  • - - -
  • - ) : null} -
    -
    - ); -} diff --git a/assets/js/components/taskAddForm.tsx b/assets/js/components/taskAddForm.tsx deleted file mode 100644 index 005333ac..00000000 --- a/assets/js/components/taskAddForm.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import {useState} from 'react'; - -import {createTask, makeTaskFromDefaults} from 'app/actions/tasks'; import {DefaultTaskValues, ValidationErrors} from -'app/types'; -import TaskQuickForm from 'app/components/taskQuickForm'; - -type Props = { - onCancel: () => void; - defaultValues?: DefaultTaskValues; -}; - -function TaskAddForm({onCancel, defaultValues}: Props): JSX.Element { - const [errors, setErrors] = useState({}); - - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); - e.persist(); - - const form = e.target as HTMLFormElement; - const formData = new FormData(form); - const promise = createTask(formData); - - return promise.catch((errors: ValidationErrors) => { - setErrors(errors); - - return promise; - }); - } - const task = makeTaskFromDefaults(defaultValues); - - return ( - - ); -} -export default TaskAddForm; diff --git a/assets/js/components/taskGroup.tsx b/assets/js/components/taskGroup.tsx deleted file mode 100644 index 561d42d2..00000000 --- a/assets/js/components/taskGroup.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {useContext, useState, useEffect, useRef} from 'react'; -import classnames from 'classnames'; -import {useDroppable} from '@dnd-kit/core'; - -import {DefaultTaskValues, Task} from 'app/types'; -import {DefaultTaskValuesContext} from 'app/providers/defaultTaskValues'; - -import SortableItem from './sortableItem'; -import TaskRow from './taskRow'; - -type Props = { - dropId: string; - tasks: Task[]; - dataTestId?: string; - activeTask?: Task | null; - focusedTask?: Task | null; - defaultTaskValues?: DefaultTaskValues; - showProject?: boolean; - showDueOn?: boolean; - showRestore?: boolean; -}; - -export default function TaskGroup({ - dropId, - dataTestId, - activeTask, - focusedTask, - tasks, - defaultTaskValues, - showProject = false, - showDueOn = false, - showRestore = false, -}: Props): JSX.Element { - const element = useRef(null); - const {over, isOver, setNodeRef} = useDroppable({id: dropId}); - const [_, updateDefaultTaskValues] = useContext(DefaultTaskValuesContext); - - const taskIds = tasks.map(t => t.id); - const hasFocus = focusedTask && taskIds.includes(focusedTask.id); - - useEffect(() => { - // If the current group has focus it should be used for new tasks. - if (!defaultTaskValues) { - return; - } - updateDefaultTaskValues({ - type: hasFocus ? 'add' : 'remove', - data: defaultTaskValues, - }); - }, [hasFocus]); - - useEffect(() => { - // Use IntersectionObserver so we can update default value context - // for the global task add button. - if (!element.current || !defaultTaskValues) { - return; - } - const options = { - root: null, - rootMargin: '0px', - threshold: 0.9, - }; - const observer = new IntersectionObserver(entries => { - const visible = entries[0].isIntersecting; - updateDefaultTaskValues({ - type: visible ? 'add' : 'remove', - data: defaultTaskValues, - }); - }, options); - observer.observe(element.current); - - return function cleanup() { - observer.disconnect(); - }; - }, [element.current]); - - const className = classnames('dnd-dropper-left-offset', { - 'dnd-dropper-active': - isOver || (over && activeTask ? taskIds.includes(activeTask.id) : null), - }); - const activeId = activeTask ? String(activeTask.id) : undefined; - - return ( -
    -
    - {tasks.map(item => { - const focused = focusedTask?.id === item.id; - return ( - - - - ); - })} -
    -
    - ); -} diff --git a/assets/js/components/taskGroupedSorter.tsx b/assets/js/components/taskGroupedSorter.tsx deleted file mode 100644 index 7b611ffe..00000000 --- a/assets/js/components/taskGroupedSorter.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useState } from 'react'; -import {createPortal} from 'react-dom'; -import {Inertia} from '@inertiajs/inertia'; -import { - DndContext, - closestCorners, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - DragEndEvent, - DragOverEvent, - DragStartEvent, -} from '@dnd-kit/core'; -import {arrayMove, sortableKeyboardCoordinates} from '@dnd-kit/sortable'; -import DragHandle from 'app/components/dragHandle'; -import TaskRow from 'app/components/taskRow'; - -import {Task} from 'app/types'; -import {insertAtIndex} from 'app/utils/array'; - -export type GroupedItems = { - key: string; - items: Task[]; - ids: string[]; - hasAdd?: boolean; -}[]; -export interface UpdaterCallback { - (task: Task, newIndex: number, destinationKey: string): UpdateData; -} - -type ChildRenderProps = { - groupedItems: GroupedItems; - activeTask: Task | null; -}; - -type Props = { - tasks: Task[]; - children: (props: ChildRenderProps) => JSX.Element; - grouper: (tasks: Task[]) => GroupedItems; - updater: UpdaterCallback; - showProject?: boolean; - showDueOn?: boolean; -}; - -export interface UpdateData { - child_order?: number; - day_order?: number; - due_on?: string; - evening?: boolean; -} - -/** - * Find a group by its group key or task id. - * - * Group keys are received when a SortableItem goes over an empty - * Droppable. Otherwise the id will be another Sortable (task). - */ -function findGroupIndex(groups: GroupedItems, id: string): number { - return groups.findIndex(group => group.key === id || group.ids.includes(id)); -} - -/** - * Abstraction around reorder lists of tasks and optimistically updating state. - */ -export default function TaskGroupedSorter({ - children, - tasks, - grouper, - updater, - showProject, - showDueOn, -}: Props): JSX.Element { - const grouped = grouper(tasks); - const [activeTask, setActiveTask] = useState(null); - const [sorted, setSorted] = useState(undefined); - const items = sorted || grouped; - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - function handleDragStart({active}: DragStartEvent) { - const activeId = Number(active.id); - setActiveTask(tasks.find(p => p.id === activeId) ?? null); - } - - function handleDragEnd({active, over}: DragEndEvent) { - setActiveTask(null); - // Dropped outside of a dropzone - if (!over) { - return; - } - const overId = over?.id || ''; - const sourceGroupIndex = findGroupIndex(items, active.id); - const destinationGroupIndex = findGroupIndex(items, overId); - - // If either group couldn't be found bail. - if (sourceGroupIndex === -1 || destinationGroupIndex === -1) { - return; - } - const sourceIndex = items[sourceGroupIndex].ids.indexOf(active.id); - if (sourceIndex === -1) { - return; - } - const destinationGroup = items[destinationGroupIndex]; - let destinationIndex = destinationGroup.ids.indexOf(overId); - if (destinationIndex === -1) { - destinationIndex = 0; - } - const task = items[sourceGroupIndex].items[sourceIndex]; - - // This looks like duplicate code, however it ensures - // that the active item doesn't animate back to an earlier position - const newItems = [...items]; - newItems[sourceGroupIndex].items = arrayMove( - newItems[sourceGroupIndex].items, - sourceIndex, - destinationIndex - ); - setSorted(newItems); - - const data = updater(task, destinationIndex, destinationGroup.key); - - Inertia.post(`/tasks/${active.id}/move`, data, { - preserveScroll: true, - onSuccess() { - setSorted(undefined); - }, - }); - } - - function handleDragOver({active, over, draggingRect}: DragOverEvent) { - const overId = over?.id || ''; - - const activeGroupIndex = findGroupIndex(items, active.id); - const overGroupIndex = findGroupIndex(items, overId); - if ( - activeGroupIndex === -1 || - overGroupIndex === -1 || - activeGroupIndex === overGroupIndex - ) { - return; - } - const activeGroup = items[activeGroupIndex]; - const overGroup = items[overGroupIndex]; - - // Use the id lists to find offsets as using the - // tasks requires another extract. - const activeTaskIndex = activeGroup.ids.indexOf(active.id); - const overTaskIndex = overGroup.ids.indexOf(overId); - - const isBelowLastItem = - over && - overTaskIndex === overGroup.ids.length - 1 && - draggingRect.offsetTop > over.rect.offsetTop + over.rect.height; - - const modifier = isBelowLastItem ? 1 : 0; - const newIndex = - overTaskIndex >= 0 ? overTaskIndex + modifier : overGroup.ids.length + 1; - const activeId = Number(active.id); - - // Remove the active item from its current group. - const newActiveGroup = { - key: activeGroup.key, - items: activeGroup.items.filter(task => task.id !== activeId), - ids: activeGroup.ids.filter(id => id !== active.id), - }; - // Splice it into the destination group. - const newOverGroup = { - key: overGroup.key, - items: insertAtIndex(overGroup.items, newIndex, activeGroup.items[activeTaskIndex]), - ids: insertAtIndex(overGroup.ids, newIndex, active.id), - }; - - const newItems = [...items]; - newItems[activeGroupIndex] = newActiveGroup; - newItems[overGroupIndex] = newOverGroup; - - // This state update allows the faded out task - // to be placed correctly - setSorted(newItems); - } - - return ( - - {children({ - groupedItems: items, - activeTask, - })} - {createPortal( - - {activeTask ? ( -
    - - -
    - ) : null} -
    , - document.body - )} -
    - ); -} diff --git a/assets/js/components/taskList.tsx b/assets/js/components/taskList.tsx deleted file mode 100644 index 98724ff4..00000000 --- a/assets/js/components/taskList.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useState } from 'react'; -import classnames from 'classnames'; - -import {Task} from 'app/types'; -import TaskRow from 'app/components/taskRow'; - -type Props = { - tasks: Task[]; - title?: React.ReactNode; - showProject?: boolean; - showDueOn?: boolean; -}; - -export default function TaskList({title, tasks, showProject, showDueOn}: Props) { - // TODO add pagination. - return ( -
    - {title &&

    {title}

    } - {tasks.map(item => ( - - ))} -
    - ); -} diff --git a/assets/js/components/taskNotes.tsx b/assets/js/components/taskNotes.tsx deleted file mode 100644 index f9c46f0c..00000000 --- a/assets/js/components/taskNotes.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import {useState} from 'react'; -import {Inertia} from '@inertiajs/inertia'; - -import {updateTask} from 'app/actions/tasks'; -import MarkdownText from 'app/components/markdownText'; -import useKeyboardShortcut from 'app/hooks/useKeyboardShortcut'; -import {t} from 'app/locale'; -import {Task} from 'app/types'; -import {InlineIcon} from './icon'; - -type Props = { - task: Task; -}; - -export default function TaskNotes({task}: Props) { - const [editing, setEditing] = useState(false); - useKeyboardShortcut(['n'], () => { - setEditing(true); - }); - - function handleSave(event: React.FormEvent): void { - event.preventDefault(); - const formData = new FormData(event.target as HTMLFormElement); - updateTask(task, formData).then(() => { - Inertia.get(`/tasks/${task.id}/view`, {}, {only: ['task'], replace: true}); - }); - } - - function handleClick(event: React.MouseEvent): void { - if (event.target instanceof HTMLElement && event.target.nodeName === 'A') { - return; - } - setEditing(true); - } - - const lines = task.body ? task.body.split('\n') : []; - if (!editing) { - return ( -
    -

    - - {t('Notes')} - -

    -
    - - {lines.length == 0 &&

    {t('Click to Edit')}

    } -
    -
    - ); - } - - return ( -
    -

    {t('Notes')}

    -