diff --git a/backend b/backend index 14394af..bb74bfe 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 14394af2b61184b2a42a0bb72b3d8c9e73547c27 +Subproject commit bb74bfedf820640ccc851cd5164644c0ad0d1943 diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index a4087a8..f3ce44f 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -15,7 +15,7 @@ import styles from './styles.module.css'; type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'quaternary' | 'transparent' | 'dropdown-item'; -const buttonVariantToClassNameMap: Record = { +const buttonVariantToClassNameMap: Record = { primary: styles.primary, secondary: styles.secondary, tertiary: styles.tertiary, @@ -24,7 +24,7 @@ const buttonVariantToClassNameMap: Record = { 'dropdown-item': styles.dropdownItem, }; -const spacingTypeToClassNameMap: Record = { +const spacingTypeToClassNameMap: Record = { none: styles.noSpacing, '2xs': styles.condensedSpacing, xs: styles.compactSpacing, diff --git a/src/components/SearchSelectInput/index.tsx b/src/components/SearchSelectInput/index.tsx index d97887d..2fb06ae 100644 --- a/src/components/SearchSelectInput/index.tsx +++ b/src/components/SearchSelectInput/index.tsx @@ -40,7 +40,7 @@ export type Props< searchOptions?: OPTION[] | undefined | null; keySelector: (option: OPTION) => OPTION_KEY; labelSelector: (option: OPTION) => string; - colorSelector?: (option: OPTION, index: number) => [string, string]; + colorSelector?: (option: OPTION, index: number) => readonly [string, string]; descriptionSelector?: (option: OPTION) => string; hideOptionFilter?: (option: OPTION) => boolean; name: NAME; diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts index 7fcd916..422ea67 100644 --- a/src/hooks/useKeyboard.ts +++ b/src/hooks/useKeyboard.ts @@ -41,7 +41,7 @@ function getNewKey( const newIndex = modulo(oldIndex + increment, options.length); - return keySelector(options[newIndex], newIndex); + return keySelector(options[newIndex] as T, newIndex); } function useKeyboard( diff --git a/src/hooks/useSpacingTokens.ts b/src/hooks/useSpacingTokens.ts index cdaca47..0dce2a8 100644 --- a/src/hooks/useSpacingTokens.ts +++ b/src/hooks/useSpacingTokens.ts @@ -72,13 +72,14 @@ function useSpacingTokens(props: Props) { : spacingVariantToTokenStartIndex[variant]; const offset = spacingTypeToOffsetMap[spacing]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion spacingValue = spacingTokens[ bound( startIndex + offset, 0, spacingTokens.length - 1, ) - ]; + ]!; } if (isNotDefined(spacing)) { diff --git a/src/utils/common.ts b/src/utils/common.ts index 5d53fa1..cc02a0b 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -122,6 +122,9 @@ export function getDurationNumber(value: string | undefined) { // :m if (value.match(/^\d{0,2}:\d{1,2}$/)) { const [hourStr, minuteStr] = value.split(':'); + if (isNotDefined(hourStr) || isNotDefined(minuteStr)) { + return null; + } return validateHhmm(hourStr, minuteStr) ?? null; } // hhmm @@ -160,6 +163,7 @@ export function getChangedItems( keySelector: (item: T) => string, ) { const initialKeysMap = listToMap(initialItems ?? [], keySelector); + const finalKeysMap = listToMap(finalItems ?? [], keySelector); const addedKeys = Object.keys(finalKeysMap).filter( @@ -170,13 +174,15 @@ export function getChangedItems( ); const updatedKeys = Object.keys(initialKeysMap).filter( (key) => { - if (isNotDefined(finalKeysMap[key])) { + // This should be safe as we are using keys from Object.keys(initialKeysMap) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const initialObj = initialKeysMap[key]!; + const finalObj = finalKeysMap[key]; + + if (isNotDefined(finalObj)) { return false; } - const initialObj = initialKeysMap[key]; - const finalObj = finalKeysMap[key]; - const initialJson = JSON.stringify( initialObj, initialObj ? Object.keys(initialObj).sort() : undefined, @@ -191,9 +197,16 @@ export function getChangedItems( ); return { - addedItems: addedKeys.map((key) => finalKeysMap[key]), - removedItems: removedKeys.map((key) => initialKeysMap[key]), - updatedItems: updatedKeys.map((key) => finalKeysMap[key]), + // NOTE: This should be safe as addedKeys is subset of Object.keys(finalKeysMap) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + addedItems: addedKeys.map((key) => finalKeysMap[key]!), + // NOTE: This should be safe as removedKeys is subset of Object.keys(initialKeysMap) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + removedItems: removedKeys.map((key) => initialKeysMap[key]!), + // NOTE: This should be safe as updatedKeys is subset of + // Object.keys(initialKeysMap) intersection Object.keys(finalKeysMap) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + updatedItems: updatedKeys.map((key) => finalKeysMap[key]!), }; } @@ -234,7 +247,9 @@ export function sortByAttributes( const currentSortResult = sortFn( a, b, - attributes[i], + // NOTE: This should be safe as we are iterating over atttributes + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + attributes[i]!, ); if (currentSortResult !== 0) { @@ -296,7 +311,9 @@ export function groupListByAttributes( ]; } - const prevListItem = list[listIndex - 1]; + // NOTE: This will always be in-bounds because we have already checked for listIndex === 0 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const prevListItem = list[listIndex - 1]!; const attributeMismatchIndex = attributes.findIndex((attribute) => { const hasSameCurrentAttribute = compareItemAttributes( listItem, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b1edd41..8b29ef2 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -27,7 +27,7 @@ export const defaultConfigValue: ConfigStorage = { collapsedGroups: [], }; -export const colorscheme: [string, string][] = [ +export const colorscheme = [ // gray 0 ['#454447', '#eaeaea'], // idigo 1 @@ -46,7 +46,7 @@ export const colorscheme: [string, string][] = [ ['#a86e00', '#fde3aa'], // horchata 8 ['#7d5327', '#ecdecc'], -]; +] as const satisfies readonly (readonly [string, string])[]; // FIXME: We should instead generate these options export const numericOptions: NumericOption[] = [ diff --git a/src/utils/routes.tsx b/src/utils/routes.tsx index 50ea96d..a195b54 100644 --- a/src/utils/routes.tsx +++ b/src/utils/routes.tsx @@ -271,15 +271,22 @@ export function unwrapRoute( ); wrappedRoutes.forEach((route) => { - if (route.parent) { - const parentId = route.parent.id; + if (!route.parent) { + return; + } + const parentId = route.parent.id; + + const parentRoute = mapping[parentId]; + if (!parentRoute) { + // eslint-disable-next-line no-console + console.error('Parent route is not defined'); + return; + } - const parentRoute = mapping[parentId]; - if (parentRoute.children) { - parentRoute.children.push(route); - } else { - parentRoute.children = [route]; - } + if (parentRoute.children) { + parentRoute.children.push(route); + } else { + parentRoute.children = [route]; } }); diff --git a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx index 67a8408..7a0c752 100644 --- a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx +++ b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx @@ -14,6 +14,7 @@ import { import { _cs, isDefined, + unique, } from '@togglecorp/fujs'; import Button from '#components/Button'; @@ -62,7 +63,7 @@ function workItemStatusKeySelector(item: WorkItemStatusOption) { function workItemStatusLabelSelector(item: WorkItemStatusOption) { return item.label; } -function workItemStatusColorSelector(item: WorkItemStatusOption): [string, string] { +function workItemStatusColorSelector(item: WorkItemStatusOption): readonly [string, string] { if (item.key === 'DOING') { return colorscheme[1]; } @@ -72,13 +73,16 @@ function workItemStatusColorSelector(item: WorkItemStatusOption): [string, strin return colorscheme[7]; } -function defaultColorSelector(_: T, i: number): [string, string] { - return colorscheme[i % colorscheme.length]; +function defaultColorSelector(_: T, i: number): readonly [string, string] { + // NOTE: This is safe as we the index is bounded + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return colorscheme[i % colorscheme.length]!; } interface Props { className?: string; workItem: WorkItem; + tasks: Task[] | undefined; contractId: string | undefined; onClone?: (clientId: string, override?: Partial) => void; @@ -90,6 +94,7 @@ function WorkItemRow(props: Props) { const { className, workItem, + tasks, contractId, onClone, onDelete, @@ -112,8 +117,18 @@ function WorkItemRow(props: Props) { ); const filteredTaskList = useMemo( - () => enums?.private?.allActiveTasks?.filter((task) => task.contract.id === contractId), - [contractId, enums], + () => ( + unique( + [ + ...enums?.private?.allActiveTasks ?? [], + ...tasks ?? [], + ], + (item) => item.id, + ).filter( + (task) => task.contract.id === contractId, + ) + ), + [contractId, enums, tasks], ); const handleStatusCheck = useCallback(() => { diff --git a/src/views/DailyJournal/DayView/index.tsx b/src/views/DailyJournal/DayView/index.tsx index 1540470..bd1b48d 100644 --- a/src/views/DailyJournal/DayView/index.tsx +++ b/src/views/DailyJournal/DayView/index.tsx @@ -15,6 +15,7 @@ import { compareString, isDefined, isNotDefined, + listToMap, sum, } from '@togglecorp/fujs'; @@ -32,6 +33,7 @@ import { import { DailyJournalAttribute, EntriesAsList, + Task, WorkItem, } from '#utils/types'; @@ -52,6 +54,7 @@ const dateFormatter = new Intl.DateTimeFormat( interface Props { className?: string; workItems: WorkItem[] | undefined; + tasks: Task[] | undefined; loading: boolean; errored: boolean; onWorkItemClone: (clientId: string, override?: Partial) => void; @@ -70,9 +73,25 @@ function DayView(props: Props) { loading, errored, selectedDate, + tasks, } = props; - const { taskById } = useContext(EnumsContext); + // FIXME: We should still get archived tasks here + const { taskById: oldTaskById } = useContext(EnumsContext); + + // FIXME: memoize this + const newTaskById = listToMap( + tasks, + (item) => item.id, + ); + + const taskById = useMemo( + () => ({ + ...oldTaskById, + ...newTaskById, + }), + [oldTaskById, newTaskById], + ); const [ storedConfig, @@ -126,15 +145,15 @@ function DayView(props: Props) { const taskDetails = taskById[item.task]; if (attr.key === 'task') { - return taskDetails.name; + return taskDetails?.name ?? 'Unknown Task'; } if (attr.key === 'contract') { - return taskDetails.contract.name; + return taskDetails?.contract.name ?? 'Unknown Contract'; } if (attr.key === 'project') { - return taskDetails.contract.project.name; + return taskDetails?.contract.project.name ?? 'Unknown Project'; } return undefined; @@ -150,6 +169,10 @@ function DayView(props: Props) { const taskDetails = taskById[item.task]; + if (!taskDetails) { + return undefined; + } + if (attr.key === 'project') { return taskDetails.contract.project.logo; } @@ -364,10 +387,6 @@ function DayView(props: Props) { return null; } - const taskDetails = taskById?.[groupedItem.value.task]; - if (!taskDetails) { - return null; - } const hidden = enableCollapsibleGroups && collapsedGroups.some( (groupKey) => groupedItem.itemKey.startsWith(groupKey), @@ -381,6 +400,8 @@ function DayView(props: Props) { || isNotDefined(groupedItem.value.duration) ); + const taskDetails = taskById?.[groupedItem.value.task]; + return (
); diff --git a/src/views/DailyJournal/EndSidebar/index.tsx b/src/views/DailyJournal/EndSidebar/index.tsx index db61445..c5002c2 100644 --- a/src/views/DailyJournal/EndSidebar/index.tsx +++ b/src/views/DailyJournal/EndSidebar/index.tsx @@ -66,7 +66,8 @@ function EndSidebar(props: Props) { (task) => task.contract.project.id, undefined, (list) => ({ - project: list[0].contract.project, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + project: list[0]!.contract.project, workItems: list, }), ), diff --git a/src/views/DailyJournal/StartSidebar/index.tsx b/src/views/DailyJournal/StartSidebar/index.tsx index 5b73ec3..cb86c67 100644 --- a/src/views/DailyJournal/StartSidebar/index.tsx +++ b/src/views/DailyJournal/StartSidebar/index.tsx @@ -217,7 +217,9 @@ function StartSidebar(props: Props) { } const [removedItem] = newAttributes.splice(sourceIndex, 1); - newAttributes.splice(destinationIndex, 0, removedItem); + // NOTE: We can assert removedItem is not undefined as sourceIndex is already checked for -1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newAttributes.splice(destinationIndex, 0, removedItem!); setConfigFieldValue(newAttributes, 'dailyJournalAttributeOrder'); }, [setConfigFieldValue, storedConfig.dailyJournalAttributeOrder]); diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index fa6045f..c0bab82 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -27,6 +27,7 @@ import { encodeDate, isDefined, isNotDefined, + unique, } from '@togglecorp/fujs'; import { gql, @@ -63,6 +64,7 @@ import { defaultConfigValue } from '#utils/constants'; import { removeNull } from '#utils/nullHelper'; import { EntriesAsList, + Task, WorkItem, } from '#utils/types'; @@ -91,6 +93,26 @@ const MY_TIME_ENTRIES_QUERY = gql` status taskId type + # We can use this infromation to get task that are already archived + task { + id + name + contract { + id + name + project { + id + name + logo { + url + } + projectClient { + id + name + } + } + } + } } journal(date: $date) { id @@ -154,6 +176,7 @@ const BULK_TIME_ENTRY_MUTATION = gql` // eslint-disable-next-line import/prefer-default-export export function Component() { const [workItems, setWorkItems] = useState([]); + const [tasks, setTasks] = useState([]); const routes = useContext(RouteContext); const navigate = useNavigate(); @@ -336,6 +359,14 @@ export function Component() { ).sort((foo, bar) => compareStringAsNumber(foo.id, bar.id)) ?? [], ); + const tasksFromServer = unique( + myTimeEntriesResult.data?.private.myTimeEntries?.flatMap( + (timeEntry) => timeEntry.task, + ) ?? [], + (item) => item.id, + ); + + setTasks(tasksFromServer); setWorkItems(workItemsFromServer); addOrUpdateServerData(workItemsFromServer); addOrUpdateStateData(workItemsFromServer); @@ -394,7 +425,9 @@ export function Component() { } const targetItem = { - ...oldWorkItems[sourceItemIndex], + // FIXME: This is safe as sourceItemIndex === -1 is already checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...oldWorkItems[sourceItemIndex]!, ...override, clientId: newId, }; @@ -430,7 +463,9 @@ export function Component() { return oldWorkItems; } - const removedItem = oldWorkItems[sourceItemIndex]; + // FIXME: This is safe as sourceItemIndex === -1 is already checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const removedItem = oldWorkItems[sourceItemIndex]!; removeFromStateData(removedItem.clientId); const newWorkItems = [...oldWorkItems]; @@ -456,7 +491,9 @@ export function Component() { return oldWorkItems; } - const obsoleteWorkItem = oldWorkItems[sourceItemIndex]; + // FIXME: This is safe as sourceItemIndex === -1 is already checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const obsoleteWorkItem = oldWorkItems[sourceItemIndex]!; const newWorkItem = { ...obsoleteWorkItem, @@ -728,6 +765,7 @@ export function Component() { loading={myTimeEntriesResult.fetching} errored={!!myTimeEntriesResult.error} workItems={filteredWorkItems} + tasks={tasks} onWorkItemClone={handleWorkItemClone} onWorkItemChange={handleWorkItemChange} onWorkItemDelete={handleWorkItemDelete} diff --git a/src/views/DailyStandup/DeadlineSection/index.tsx b/src/views/DailyStandup/DeadlineSection/index.tsx index 82e5a28..779293b 100644 --- a/src/views/DailyStandup/DeadlineSection/index.tsx +++ b/src/views/DailyStandup/DeadlineSection/index.tsx @@ -111,7 +111,10 @@ function DeadlineSection() { generalEvent={generalEvent} /> {generalEvent.remainingDays < 0 - && upcomingEvents[index + 1]?.remainingDays >= 0 + && upcomingEvents[index + 1] + // NOTE: This is safe as upcomingEvents[index + 1] is already checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + && upcomingEvents[index + 1]!.remainingDays >= 0 && (
diff --git a/src/views/DailyStandup/index.tsx b/src/views/DailyStandup/index.tsx index 2a34c0c..5b54e48 100644 --- a/src/views/DailyStandup/index.tsx +++ b/src/views/DailyStandup/index.tsx @@ -100,7 +100,7 @@ export function Component() { const projectsMap = useMemo(() => { const allProjectsData = allProjectsResponse?.data?.private.allProjects; - if (isNotDefined(allProjectsData)) { + if (isNotDefined(allProjectsData) || allProjectsData.length <= 0) { return undefined; } @@ -111,10 +111,14 @@ export function Component() { }, deadlines: { prev: 'start', - next: allProjectsData[0].id, + // NOTE: This is safe because allProjectsData.length has been checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + next: allProjectsData[0]!.id, }, end: { - prev: allProjectsData[allProjectsData.length - 1].id, + // NOTE: This is safe because allProjectsData.length has been checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + prev: allProjectsData[allProjectsData.length - 1]!.id, next: undefined, }, }; @@ -122,8 +126,12 @@ export function Component() { return allProjectsData.reduce( (acc, val, index) => { const currentMap = { - next: index === (allProjectsData.length - 1) ? 'end' : allProjectsData[index + 1].id, - prev: index === 0 ? 'deadlines' : allProjectsData[index - 1].id, + // NOTE: This is safe because boundary for allProjectsData has been checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + next: index === (allProjectsData.length - 1) ? 'end' : allProjectsData[index + 1]!.id, + // NOTE: This is safe because boundary for allProjectsData has been checked + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + prev: index === 0 ? 'deadlines' : allProjectsData[index - 1]!.id, }; acc[val.id] = currentMap; @@ -166,10 +174,10 @@ export function Component() { }, [setUrlQuery]); const mapId = urlQuery.page ?? urlQuery.project ?? 'start'; - const prevButtonName = projectsMap?.[mapId].prev; + const prevButtonName = projectsMap?.[mapId]?.prev; const prevButtonDisabled = isNotDefined(prevButtonName); - const nextButtonName = projectsMap?.[mapId].next; + const nextButtonName = projectsMap?.[mapId]?.next; const nextButtonDisabled = isNotDefined(nextButtonName); const handleNextButtion = useCallback( diff --git a/src/views/Settings/index.tsx b/src/views/Settings/index.tsx index 9ac3c7d..e293932 100644 --- a/src/views/Settings/index.tsx +++ b/src/views/Settings/index.tsx @@ -41,7 +41,7 @@ function workItemStatusKeySelector(item: WorkItemStatusOption) { function workItemStatusLabelSelector(item: WorkItemStatusOption) { return item.label; } -function workItemStatusColorSelector(item: WorkItemStatusOption): [string, string] { +function workItemStatusColorSelector(item: WorkItemStatusOption): readonly [string, string] { if (item.key === 'DOING') { return colorscheme[1]; } @@ -51,8 +51,10 @@ function workItemStatusColorSelector(item: WorkItemStatusOption): [string, strin return colorscheme[7]; } -function defaultColorSelector(_: T, i: number): [string, string] { - return colorscheme[i % colorscheme.length]; +function defaultColorSelector(_: T, i: number): readonly [string, string] { + // NOTE: This is safe as we the index is bounded + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return colorscheme[i % colorscheme.length]!; } /** @knipignore */ diff --git a/tsconfig.json b/tsconfig.json index 2bfec44..e0371ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,10 @@ ], "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + "noUncheckedIndexedAccess": true + /* "noPropertyAccessFromIndexSignature": true */ }, "include": [ "src",