diff --git a/backend b/backend index 3f638e0..dba567a 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 3f638e051c66d561b36758e28553a0f188f461bb +Subproject commit dba567a01be781864ffe2b03bab92d387cbac242 diff --git a/src/PwaPrompt/index.tsx b/src/App/PwaPrompt/index.tsx similarity index 100% rename from src/PwaPrompt/index.tsx rename to src/App/PwaPrompt/index.tsx diff --git a/src/PwaPrompt/styles.module.css b/src/App/PwaPrompt/styles.module.css similarity index 100% rename from src/PwaPrompt/styles.module.css rename to src/App/PwaPrompt/styles.module.css diff --git a/src/App/index.tsx b/src/App/index.tsx index 53db948..2f73376 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -1,112 +1,50 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; import { createBrowserRouter, RouterProvider, } from 'react-router-dom'; import * as Sentry from '@sentry/react'; +import { cacheExchange } from '@urql/exchange-graphcache'; import { - encodeDate, - listToMap, -} from '@togglecorp/fujs'; -import { - gql, - useQuery, + Client as UrqlClient, + fetchExchange, + Provider as UrqlProvider, } from 'urql'; -import DateContext from '#contexts/date'; -import EnumsContext, { EnumsContextProps } from '#contexts/enums'; -import LocalStorageContext, { LocalStorageContextProps } from '#contexts/localStorage'; -import NavbarContext, { NavbarContextProps } from '#contexts/navbar'; import RouteContext from '#contexts/route'; -import SizeContext, { SizeContextProps } from '#contexts/size'; -import UserContext, { - UserAuth, - UserContextProps, -} from '#contexts/user'; -import { - EnumsQuery, - EnumsQueryVariables, - MeQuery, - MeQueryVariables, -} from '#generated/types/graphql'; -import useThrottledValue from '#hooks/useThrottledValue'; -import { getWindowSize } from '#utils/common'; -import { defaultConfigValue } from '#utils/constants'; -import { getFromStorage } from '#utils/localStorage'; -import { ConfigStorage } from '#utils/types'; - +import icon from '#resources/icon.svg'; + +import AuthProvider from './providers/AuthProvider'; +import CommandProvider from './providers/CommandProvider'; +import DateProvider from './providers/DateProvider'; +import EnumsProvider from './providers/EnumsProvider'; +import LocalStorageProvider from './providers/LocalStorageProvider'; +import NavbarProvider from './providers/NavbarProvider'; +import SizeProvider from './providers/SizeProvider'; +import PwaPrompt from './PwaPrompt'; import wrappedRoutes, { unwrappedRoutes } from './routes'; import styles from './styles.module.css'; -const ME_QUERY = gql` - query Me { - public { - id - me { - displayName - displayPicture - email - firstName - id - lastName - isStaff - loginExpire - } - } - } -`; - -const ENUMS_QUERY = gql` - query Enums { - enums { - JournalWfhType { - key - label - } - JournalLeaveType { - key - label - } - TimeEntryStatus { - key - label - } - TimeEntryType { - key - label - } - } - private { - id - allActiveTasks { - id - name - contract { - id - name - project { - id - name - logo { - url - } - projectClient { - id - name - } - } - } - } - } - } -`; +const gqlClient = new UrqlClient({ + url: `${import.meta.env.APP_GRAPHQL_DOMAIN}/graphql/`, + exchanges: [cacheExchange({ + keys: { + PrivateQuery: () => null, + PublicQuery: () => null, + AppEnumCollection: () => null, + DailyStandUpType: () => null, + AppEnumCollectionTimeEntryType: (item) => String(item.key), + AppEnumCollectionTimeEntryStatus: (item) => String(item.key), + AppEnumCollectionJournalLeaveType: (item) => String(item.key), + AppEnumCollectionJournalWfhType: (item) => String(item.key), + DjangoImageType: (item) => String(item.url), + }, + }), fetchExchange], + fetchOptions: () => ({ + credentials: 'include', + }), + requestPolicy: 'network-only', +}); const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter( createBrowserRouter, @@ -114,219 +52,43 @@ const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter( const router = sentryCreateBrowserRouter(unwrappedRoutes); -function App() { - // Date - - const [date, setDate] = useState(() => { - const today = new Date(); - return { - fullDate: encodeDate(today), - year: today.getFullYear(), - month: today.getMonth(), - day: today.getDate(), - }; - }); - - useEffect( - () => { - const timeout = window.setInterval( - () => { - setDate((oldValue) => { - const today = new Date(); - const newDateString = encodeDate(today); - if (oldValue.fullDate === newDateString) { - return oldValue; - } - return { - fullDate: newDateString, - year: today.getFullYear(), - month: today.getMonth(), - day: today.getDate(), - }; - }); - }, - 2000, - ); - return () => { - window.clearInterval(timeout); - }; - }, - [], - ); - - // Local Storage - - const [storageState, setStorageState] = useState(() => { - const configValue = getFromStorage('timur-config'); - return ({ - 'timur-config': { - value: configValue, - defaultValue: defaultConfigValue, - }, - }); - }); - - const handleStorageStateUpdate: typeof setStorageState = useCallback( - (val) => { - setStorageState((prevValue) => { - const newValue = typeof val === 'function' - ? val(prevValue) - : val; - - if ( - prevValue['timur-config'].value?.dailyJournalGrouping !== newValue['timur-config'].value?.dailyJournalGrouping - || prevValue['timur-config'].value?.dailyJournalAttributeOrder !== newValue['timur-config'].value?.dailyJournalAttributeOrder - ) { - const overriddenValue: typeof newValue = { - ...newValue, - 'timur-config': { - ...newValue['timur-config'], - value: { - ...(newValue['timur-config'].value ?? defaultConfigValue), - collapsedGroups: [], - }, - }, - }; - return overriddenValue; - } - - return newValue; - }); - }, - [], - ); - - const storageContextValue = useMemo(() => ({ - storageState, - setStorageState: handleStorageStateUpdate, - }), [storageState, handleStorageStateUpdate]); - - // Device Size - - const [size, setSize] = useState(getWindowSize); - const throttledSize = useThrottledValue(size); - useEffect(() => { - function handleResize() { - setSize(getWindowSize()); - } - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - - // Authentication - - const [userAuth, setUserAuth] = useState(); - const [ready, setReady] = useState(false); - - const [meResult] = useQuery( - { query: ME_QUERY }, - ); - - useEffect(() => { - if (meResult.fetching) { - return; - } - setUserAuth(meResult.data?.public.me ?? undefined); - setReady(true); - }, [meResult.data, meResult.fetching]); - - const removeUserAuth = useCallback( - () => { - setUserAuth(undefined); - }, - [], - ); - - const userContextValue = useMemo( - () => ({ - userAuth, - setUserAuth, - removeUserAuth, - }), - [userAuth, removeUserAuth], - ); - - // Enums - - const [enumsResult] = useQuery( - { - query: ENUMS_QUERY, - requestPolicy: 'cache-and-network', - }, - ); - - const enumsContextValue = useMemo( - () => ({ - enums: enumsResult.data, - taskById: listToMap( - enumsResult.data?.private.allActiveTasks, - ({ id }) => id, - ), - statusByKey: listToMap( - enumsResult.data?.enums.TimeEntryStatus, - ({ key }) => key, - ), - typeByKey: listToMap( - enumsResult.data?.enums.TimeEntryType, - ({ key }) => key, - ), - }), - [enumsResult], - ); - - // Page layouts - - const navbarStartActionRef = useRef(null); - const navbarMidActionRef = useRef(null); - const navbarEndActionRef = useRef(null); - - const navbarContextValue = useMemo(() => ({ - startActionsRef: navbarStartActionRef, - midActionsRef: navbarMidActionRef, - endActionsRef: navbarEndActionRef, - }), []); - - // Route - - const fallbackElement = ( -
- Timur Icon -
- ); - - // NOTE: We should block page for authentication before we mount routes - // TODO: Handle error with authentication - if (!ready) { - return fallbackElement; - } +const fallbackElement = ( +
+ Timur Icon +
+); +function App() { return ( - - - - - - - - - - - - - - - + <> + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/App/providers/AuthProvider/index.tsx b/src/App/providers/AuthProvider/index.tsx new file mode 100644 index 0000000..f87ca1e --- /dev/null +++ b/src/App/providers/AuthProvider/index.tsx @@ -0,0 +1,103 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + gql, + useQuery, +} from 'urql'; + +import UserContext, { + UserAuth, + UserContextProps, +} from '#contexts/user'; +import { + MeQuery, + MeQueryVariables, +} from '#generated/types/graphql'; +import icon from '#resources/icon.svg'; + +import styles from './styles.module.css'; + +const ME_QUERY = gql` + query Me { + public { + id + me { + displayName + displayPicture + email + firstName + id + lastName + isStaff + loginExpire + } + } + } +`; + +const fallbackElement = ( +
+ Timur Icon +
+); + +interface BaseProps { + children: React.ReactNode; +} + +function AuthProvider(props: BaseProps) { + const { children } = props; + + const [userAuth, setUserAuth] = useState(); + const [ready, setReady] = useState(false); + + const [meResult] = useQuery( + { query: ME_QUERY }, + ); + + useEffect(() => { + if (meResult.fetching) { + return; + } + setUserAuth(meResult.data?.public.me ?? undefined); + setReady(true); + }, [meResult.data, meResult.fetching]); + + const removeUserAuth = useCallback( + () => { + setUserAuth(undefined); + }, + [], + ); + + const userContextValue = useMemo( + () => ({ + userAuth, + setUserAuth, + removeUserAuth, + }), + [userAuth, removeUserAuth], + ); + + // NOTE: We should block page for authentication before we mount routes + if (!ready) { + // TODO: Handle error with authentication + return fallbackElement; + } + + return ( + + {children} + + ); +} + +export default AuthProvider; diff --git a/src/App/providers/AuthProvider/styles.module.css b/src/App/providers/AuthProvider/styles.module.css new file mode 100644 index 0000000..ce3146d --- /dev/null +++ b/src/App/providers/AuthProvider/styles.module.css @@ -0,0 +1,15 @@ +.fallback-element { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + width: 100vw; + height: 100vh; + gap: 1rem; + + .app-logo { + margin-top: -4rem; + height: 6rem; + } +} + diff --git a/src/App/providers/CommandProvider/index.tsx b/src/App/providers/CommandProvider/index.tsx new file mode 100644 index 0000000..0b8db90 --- /dev/null +++ b/src/App/providers/CommandProvider/index.tsx @@ -0,0 +1,301 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { _cs } from '@togglecorp/fujs'; +import { + gql, + useMutation, +} from 'urql'; + +import CommandContext, { CommandContextProps } from '#contexts/command'; +import { + CudTimeEntryMutation, + CudTimeEntryMutationVariables, +} from '#generated/types/graphql'; +import icon from '#resources/icon.svg'; +import { + AddCommand, + Command, + DeleteCommand, + EditCommand, +} from '#utils/command'; +import { WorkItem } from '#utils/types'; + +import styles from './styles.module.css'; + +const CUD_TIME_ENTRY_MUTATION = gql` + mutation CudTimeEntry( + $createItems: [TimeEntryBulkCreateInput!], + $updateItems: [TimeEntryBulkUpdateInput!], + $deleteIds: [ID!], + ) { + private { + cudTimeEntry( + createItems: $createItems, + updateItems: $updateItems, + deleteIds: $deleteIds + ) { + deleted { + id + clientId + } + errors + createItems { + id + clientId + date + description + duration + startTime + status + taskId + type + } + updateItems { + id + clientId + date + description + duration + startTime + status + taskId + type + } + } + } + } +`; + +function isAddAction(item: Command): item is AddCommand { + return item.type === 'add'; +} + +function isEditAction(item: Command): item is EditCommand { + return item.type === 'edit'; +} +function isDeleteAction(item: Command): item is DeleteCommand { + return item.type === 'delete'; +} + +interface BaseProps { + children: React.ReactNode; +} + +function CommandProvider(props: BaseProps) { + const { children } = props; + + const zeitgeist = useRef(0); + const commands = useRef[]>([]); + const [undoable, setUndobale] = useState(false); + const [redoable, setRedoable] = useState(false); + const setCommands = useCallback( + (value: Command[]) => { + commands.current = value; + const forwardSpace = commands.current.length - zeitgeist.current; + setRedoable(forwardSpace > 0); + const backwardSpace = zeitgeist.current; + setUndobale(backwardSpace > 0); + }, + [], + ); + + const serverCommands = useRef[]>([]); + const [ + serverCommandsLastUpdated, + setServerCommandsLastUpdated, + ] = useState(undefined); + const setServerCommands = useCallback( + (value: Command[]) => { + serverCommands.current = value; + if (serverCommands.current.length === 0) { + setServerCommandsLastUpdated(undefined); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const val = serverCommands.current[serverCommands.current.length - 1]!.timestamp; + setServerCommandsLastUpdated(val); + } + }, + [], + ); + + const inFlightServerCommands = useRef[]>([]); + const [inFlight, setInFlight] = useState(false); + + const [ + , + triggerCudTimeEntryMutation, + ] = useMutation( + CUD_TIME_ENTRY_MUTATION, + ); + + useEffect( + () => { + if (inFlight || !serverCommandsLastUpdated) { + return; + } + + if (serverCommands.current.length === 0) { + return; + } + + inFlightServerCommands.current = serverCommands.current.slice(0, 20); + setServerCommands(serverCommands.current.slice(20)); + + setInFlight(true); + + async function mutate() { + try { + const addedItems = inFlightServerCommands.current.filter(isAddAction); + const editedItems = inFlightServerCommands.current.filter(isEditAction); + const deletedItems = inFlightServerCommands.current.filter(isDeleteAction); + const res = await triggerCudTimeEntryMutation({ + createItems: addedItems.map((item) => item.newValue), + updateItems: editedItems.map((item) => { + const finalItem = { + ...item.newValue, + clientId: item.key, + }; + // NOTE: We want to replace all undefined with null so that + // we can indicate to server that the fields should be cleared + Object.entries(finalItem).forEach(([field, value]) => { + finalItem[field as keyof typeof finalItem] = value ?? null; + }); + return finalItem; + }), + deleteIds: deletedItems.map((item) => item.oldValue.clientId), + }); + + // eslint-disable-next-line no-console + console.debug(res); + } catch (ex) { + setServerCommands([ + ...inFlightServerCommands.current, + ...serverCommands.current, + ]); + } + inFlightServerCommands.current = []; + setInFlight(false); + } + + // NOTE: This will act as a rate limit + setTimeout( + mutate, + 1000, + ); + }, + [inFlight, serverCommandsLastUpdated, setServerCommands, triggerCudTimeEntryMutation], + ); + + const setZeitgeist = useCallback( + (value: number) => { + zeitgeist.current = value; + const forwardSpace = commands.current.length - zeitgeist.current; + setRedoable(forwardSpace > 0); + const backwardSpace = zeitgeist.current; + setUndobale(backwardSpace > 0); + }, + [], + ); + + const watch = useCallback( + (action: Command) => { + const oldActions = serverCommands.current; + const existingActionIndex = oldActions.findIndex((item) => item.key === action.key); + if (existingActionIndex === -1) { + setServerCommands([...oldActions, action]); + return; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existingAction = oldActions[existingActionIndex]!; + const newActions = [...oldActions]; + if (existingAction.type === 'add' && action.type === 'delete') { + // we remove from list + newActions.splice(existingActionIndex, 1); + } else if (existingAction.type === 'add' && action.type === 'edit') { + // we update the add + newActions.splice( + existingActionIndex, + 1, + { + ...existingAction, + timestamp: action.timestamp, + newValue: { + ...existingAction.newValue, + ...action.newValue, + }, + }, + ); + } else if (existingAction.type === 'edit' && action.type === 'edit') { + // we update the edit + newActions.splice( + existingActionIndex, + 1, + { + ...existingAction, + timestamp: action.timestamp, + newValue: { + ...existingAction.newValue, + ...action.newValue, + }, + }, + ); + } else if (existingAction.type === 'edit' && action.type === 'delete') { + // we replace with delete + newActions.splice( + existingActionIndex, + 1, + action, + ); + } else if (existingAction.type === 'delete' && action.type === 'add') { + // we remove from list + newActions.splice( + existingActionIndex, + 1, + ); + } else { + // eslint-disable-next-line no-console + console.error(`We previously had ${existingAction.type} but then we got ${action.type}`); + } + setServerCommands(newActions); + }, + [setServerCommands], + ); + + const commandState = useMemo((): CommandContextProps => ({ + commands, + zeitgeist, + setZeitgeist, + setCommands, + watch, + undoable, + redoable, + }), [watch, setZeitgeist, setCommands, undoable, redoable]); + + return ( + + {children} +
+ Timur Icon +
+ Committing... +
+
+
+ ); +} + +export default CommandProvider; diff --git a/src/App/providers/CommandProvider/styles.module.css b/src/App/providers/CommandProvider/styles.module.css new file mode 100644 index 0000000..7b1f26a --- /dev/null +++ b/src/App/providers/CommandProvider/styles.module.css @@ -0,0 +1,53 @@ +.last-saved-status { + display: flex; + position: fixed; + right: var(--spacing-md); + bottom: var(--spacing-md); + align-items: center; + transition: .5s opacity ease-in-out; + opacity: 0; + border-radius: 0.5rem; + background-color: var(--color-quaternary); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-light); + gap: var(--spacing-xs); + + .timur-icon { + height: 1rem; + } + + &.active { + opacity: 1; + + .timur-icon { + animation: shake-it-off 2.2s ease-in infinite; + } + } +} + +@keyframes shake-it-off { + 10% { + transform: rotate(0); + } + 20% { + transform: rotate(-160deg); + } + 30% { + transform: rotate(-160deg) translateY(2px); + } + 35% { + transform: rotate(-160deg) translateY(-2px); + } + 50% { + transform: rotate(-160deg) translateY(2px); + } + 55% { + transform: rotate(-160deg) translateY(-2px); + } + 70% { + transform: rotate(-160deg) translateY(0); + } + 80% { + transform: rotate(0); + } +} diff --git a/src/App/providers/DateProvider.tsx b/src/App/providers/DateProvider.tsx new file mode 100644 index 0000000..ffcd840 --- /dev/null +++ b/src/App/providers/DateProvider.tsx @@ -0,0 +1,60 @@ +import { + useEffect, + useState, +} from 'react'; +import { encodeDate } from '@togglecorp/fujs'; + +import DateContext from '#contexts/date'; + +interface BaseProps { + children: React.ReactNode; +} + +function DateProvider(props: BaseProps) { + const { children } = props; + + const [date, setDate] = useState(() => { + const today = new Date(); + return { + fullDate: encodeDate(today), + year: today.getFullYear(), + month: today.getMonth(), + day: today.getDate(), + }; + }); + + useEffect( + () => { + const timeout = window.setInterval( + () => { + setDate((oldValue) => { + const today = new Date(); + const newDateString = encodeDate(today); + if (oldValue.fullDate === newDateString) { + return oldValue; + } + return { + fullDate: newDateString, + year: today.getFullYear(), + month: today.getMonth(), + day: today.getDate(), + }; + }); + }, + 2000, + ); + return () => { + window.clearInterval(timeout); + }; + }, + [], + ); + + return ( + + {children} + + ); +} + +export default DateProvider; diff --git a/src/App/providers/EnumsProvider.tsx b/src/App/providers/EnumsProvider.tsx new file mode 100644 index 0000000..53cdd43 --- /dev/null +++ b/src/App/providers/EnumsProvider.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { listToMap } from '@togglecorp/fujs'; +import { + gql, + useQuery, +} from 'urql'; + +import EnumsContext, { EnumsContextProps } from '#contexts/enums'; +import { + EnumsQuery, + EnumsQueryVariables, +} from '#generated/types/graphql'; + +const ENUMS_QUERY = gql` + query Enums { + enums { + JournalWfhType { + key + label + } + JournalLeaveType { + key + label + } + TimeEntryStatus { + key + label + } + TimeEntryType { + key + label + } + } + private { + id + allActiveTasks { + id + name + contract { + id + name + project { + id + name + logo { + url + } + projectClient { + id + name + } + } + } + } + } + } +`; + +interface BaseProps { + children: React.ReactNode; +} + +function EnumsProvider(props: BaseProps) { + const { children } = props; + + const [enumsResult] = useQuery( + { + query: ENUMS_QUERY, + requestPolicy: 'cache-and-network', + }, + ); + + const enumsContextValue = useMemo( + () => ({ + enums: enumsResult.data, + taskById: listToMap( + enumsResult.data?.private.allActiveTasks, + ({ id }) => id, + ), + statusByKey: listToMap( + enumsResult.data?.enums.TimeEntryStatus, + ({ key }) => key, + ), + typeByKey: listToMap( + enumsResult.data?.enums.TimeEntryType, + ({ key }) => key, + ), + }), + [enumsResult], + ); + + return ( + + {children} + + ); +} + +export default EnumsProvider; diff --git a/src/App/providers/LocalStorageProvider.tsx b/src/App/providers/LocalStorageProvider.tsx new file mode 100644 index 0000000..845b517 --- /dev/null +++ b/src/App/providers/LocalStorageProvider.tsx @@ -0,0 +1,70 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; + +import LocalStorageContext, { LocalStorageContextProps } from '#contexts/localStorage'; +import { defaultConfigValue } from '#utils/constants'; +import { getFromStorage } from '#utils/localStorage'; +import { ConfigStorage } from '#utils/types'; + +interface BaseProps { + children: React.ReactNode; +} + +function LocalStorageProvider(props: BaseProps) { + const { children } = props; + const [storageState, setStorageState] = useState(() => { + const configValue = getFromStorage('timur-config'); + return ({ + 'timur-config': { + value: configValue, + defaultValue: defaultConfigValue, + }, + }); + }); + + const handleStorageStateUpdate: typeof setStorageState = useCallback( + (val) => { + setStorageState((prevValue) => { + const newValue = typeof val === 'function' + ? val(prevValue) + : val; + + if ( + prevValue['timur-config'].value?.dailyJournalGrouping !== newValue['timur-config'].value?.dailyJournalGrouping + || prevValue['timur-config'].value?.dailyJournalAttributeOrder !== newValue['timur-config'].value?.dailyJournalAttributeOrder + ) { + const overriddenValue: typeof newValue = { + ...newValue, + 'timur-config': { + ...newValue['timur-config'], + value: { + ...(newValue['timur-config'].value ?? defaultConfigValue), + collapsedGroups: [], + }, + }, + }; + return overriddenValue; + } + + return newValue; + }); + }, + [], + ); + + const storageContextValue = useMemo(() => ({ + storageState, + setStorageState: handleStorageStateUpdate, + }), [storageState, handleStorageStateUpdate]); + + return ( + + {children} + + ); +} + +export default LocalStorageProvider; diff --git a/src/App/providers/NavbarProvider.tsx b/src/App/providers/NavbarProvider.tsx new file mode 100644 index 0000000..079704a --- /dev/null +++ b/src/App/providers/NavbarProvider.tsx @@ -0,0 +1,31 @@ +import { + useMemo, + useRef, +} from 'react'; + +import NavbarContext, { NavbarContextProps } from '#contexts/navbar'; + +interface BaseProps { + children: React.ReactNode; +} + +function NavbarProvider(props: BaseProps) { + const { children } = props; + const navbarStartActionRef = useRef(null); + const navbarMidActionRef = useRef(null); + const navbarEndActionRef = useRef(null); + + const navbarContextValue = useMemo(() => ({ + startActionsRef: navbarStartActionRef, + midActionsRef: navbarMidActionRef, + endActionsRef: navbarEndActionRef, + }), []); + + return ( + + {children} + + ); +} + +export default NavbarProvider; diff --git a/src/App/providers/SizeProvider.tsx b/src/App/providers/SizeProvider.tsx new file mode 100644 index 0000000..4a6426b --- /dev/null +++ b/src/App/providers/SizeProvider.tsx @@ -0,0 +1,39 @@ +import { + useEffect, + useState, +} from 'react'; + +import SizeContext, { SizeContextProps } from '#contexts/size'; +import useThrottledValue from '#hooks/useThrottledValue'; +import { getWindowSize } from '#utils/common'; + +interface BaseProps { + children: React.ReactNode; +} + +function SizeProvider(props: BaseProps) { + const { children } = props; + + const [size, setSize] = useState(getWindowSize); + const throttledSize = useThrottledValue(size); + + useEffect(() => { + function handleResize() { + setSize(getWindowSize()); + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + + {children} + + ); +} + +export default SizeProvider; diff --git a/src/components/CalendarInput/index.tsx b/src/components/CalendarInput/index.tsx deleted file mode 100644 index 1e9bf87..0000000 --- a/src/components/CalendarInput/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - useCallback, - useContext, - useState, -} from 'react'; - -import Button, { Props as ButtonProps } from '#components//Button'; -import Dialog from '#components/Dialog'; -import MonthlyCalendar from '#components/MonthlyCalendar'; -import DateContext from '#contexts/date'; - -import styles from './styles.module.css'; - -interface Props extends Omit, 'onClick' | 'onChange'> { - value: string | undefined, - onChange: (value: string | undefined) => void; -} - -function CalendarInput(props: Props) { - const { - value, - onChange, - ...buttonProps - } = props; - - const [confirmationShown, setConfirmationShown] = useState(false); - const { year, month } = useContext(DateContext); - - const handleModalOpen = useCallback( - () => { - setConfirmationShown(true); - }, - [], - ); - - const handleModalClose = useCallback( - () => { - setConfirmationShown(false); - }, - [], - ); - - const handleDateClick = useCallback( - (newValue: string) => { - onChange(newValue); - setConfirmationShown(false); - }, - [onChange], - ); - - return ( - <> - )} {screen === 'desktop' && ( @@ -752,10 +696,25 @@ export function Component() { name={undefined} variant="quaternary" onClick={handleShortcutsButtonClick} + icons={( + + )} > - + Shortcuts )} + {screen === 'desktop' && ( + + )} + > + Settings + + )} Add entry - {selectedDate !== fullDate && ( - - Go to today - - )} + + ( compareNumber( diff --git a/src/views/DailyStandup/styles.module.css b/src/views/DailyStandup/styles.module.css index 937691b..acd964e 100644 --- a/src/views/DailyStandup/styles.module.css +++ b/src/views/DailyStandup/styles.module.css @@ -9,10 +9,6 @@ gap: var(--spacing-lg); overflow: auto; - &:not(.presentation-mode) { - border: var(--width-separator-sm) solid var(--color-separator); - } - .content { display: flex; flex-direction: column; diff --git a/src/views/Home/index.tsx b/src/views/Home/index.tsx index 0f6f5a8..d6d35e8 100644 --- a/src/views/Home/index.tsx +++ b/src/views/Home/index.tsx @@ -4,7 +4,6 @@ import { } from 'react'; import { FcCalendar, - FcSettings, FcVoicePresentation, } from 'react-icons/fc'; import { useNavigate } from 'react-router-dom'; @@ -65,13 +64,6 @@ export function Component() { > Standup Deck - } - > - Settings - ); diff --git a/src/views/RootLayout/index.tsx b/src/views/RootLayout/index.tsx index 5001b4c..a19eacb 100644 --- a/src/views/RootLayout/index.tsx +++ b/src/views/RootLayout/index.tsx @@ -47,15 +47,15 @@ export function Component() { )} /> )} - {userAuth && daysBeforeLogout < REMAINING_DAYS_THRESHOLD && ( -
- {`You'll be automatically logged out in ${Math.floor(daysBeforeLogout)} days. Please re-login to avoid unexpected logout.`} -
- )}
+ {userAuth && daysBeforeLogout < REMAINING_DAYS_THRESHOLD && ( +
+ {`You'll be automatically logged out in ${Math.floor(daysBeforeLogout)} days unless you re-login.`} +
+ )} ); } diff --git a/src/views/Settings/index.tsx b/src/views/Settings/index.tsx index b4cd409..348fed9 100644 --- a/src/views/Settings/index.tsx +++ b/src/views/Settings/index.tsx @@ -1,19 +1,162 @@ -import { useContext } from 'react'; +import { + useCallback, + useContext, + useMemo, +} from 'react'; +import { RiDraggable } from 'react-icons/ri'; +import { + closestCenter, + DndContext, + DragEndEvent, + DraggableAttributes, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; import Checkbox from '#components/Checkbox'; import Page from '#components/Page'; +import RadioInput from '#components/RadioInput'; import SelectInput from '#components/SelectInput'; import EnumsContext from '#contexts/enums'; import { EnumsQuery } from '#generated/types/graphql'; import useLocalStorage from '#hooks/useLocalStorage'; import useSetFieldValue from '#hooks/useSetFieldValue'; -import { colorscheme } from '#utils/constants'; -import { EditingMode } from '#utils/types'; +import { + colorscheme, + defaultConfigValue, + numericOptionKeySelector, + numericOptionLabelSelector, + numericOptions, +} from '#utils/constants'; +import { + DailyJournalAttribute, + DailyJournalAttributeKeys, + DailyJournalGrouping, + EditingMode, +} from '#utils/types'; import WorkItemRow from '../DailyJournal/DayView/WorkItemRow'; import styles from './styles.module.css'; +const dailyJournalAttributeDetails: Record = { + project: { label: 'Project' }, + contract: { label: 'Contract' }, + task: { label: 'Task' }, + status: { label: 'Status' }, +}; + +interface ItemProps { + className?: string; + attribute: DailyJournalAttribute; + setNodeRef?: (node: HTMLElement | null) => void; + draggableAttributes?: DraggableAttributes; + draggableListeners?: SyntheticListenerMap | undefined; + transformStyle?: string | undefined; + transitionStyle?: string | undefined; +} + +function Item(props: ItemProps) { + const { + className, + setNodeRef, + attribute, + draggableAttributes, + draggableListeners, + transformStyle, + transitionStyle, + } = props; + + return ( +
+
+ +
+
+ {dailyJournalAttributeDetails[attribute.key].label} +
+
+ ); +} + +interface SortableItemProps { + className?: string; + attribute: DailyJournalAttribute; +} + +function SortableItem(props: SortableItemProps) { + const { + attribute, + className, + } = props; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + over, + } = useSortable({ id: attribute.key }); + + const transformStyle = useMemo(() => { + if (isNotDefined(transform)) { + return undefined; + } + + const transformations = [ + // isDefined(transform.x) && `translateX(${transform.x}px)`, + isDefined(transform.y) && `translateY(${transform.y}px)`, + isDefined(transform.scaleX) && `scaleY(${transform.scaleX})`, + isDefined(transform.scaleY) && `scaleY(${transform.scaleY})`, + ]; + + return transformations.filter(Boolean).join(' '); + }, [transform]); + + return ( + + ); +} + type EditingOption = { key: EditingMode, label: string }; function editingOptionKeySelector(item: EditingOption) { return item.key; @@ -64,6 +207,58 @@ export function Component() { const [storedConfig, setStoredConfig] = useLocalStorage('timur-config'); const setConfigFieldValue = useSetFieldValue(setStoredConfig); + const updateJournalGrouping = useCallback((value: number, name: 'groupLevel' | 'joinLevel') => { + const oldValue = storedConfig.dailyJournalGrouping + ?? defaultConfigValue.dailyJournalGrouping; + + if (name === 'groupLevel') { + setConfigFieldValue({ + groupLevel: value, + joinLevel: Math.min(oldValue.joinLevel, value), + } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); + + return; + } + + setConfigFieldValue({ + groupLevel: oldValue.groupLevel, + joinLevel: Math.min(oldValue.groupLevel, value), + } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); + }, [storedConfig.dailyJournalGrouping, setConfigFieldValue]); + + const sensors = useSensors( + useSensor(PointerSensor), + ); + + const handleDndEnd = useCallback((dragEndEvent: DragEndEvent) => { + const { + active, + over, + } = dragEndEvent; + + const oldAttributes = storedConfig.dailyJournalAttributeOrder + ?? defaultConfigValue.dailyJournalAttributeOrder; + + if (isNotDefined(active) || isNotDefined(over)) { + return; + } + + const newAttributes = [...oldAttributes]; + const sourceIndex = newAttributes.findIndex(({ key }) => active.id === key); + const destinationIndex = newAttributes.findIndex(({ key }) => over.id === key); + + if (sourceIndex === -1 || destinationIndex === -1) { + return; + } + + const [removedItem] = newAttributes.splice(sourceIndex, 1); + // 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]); + return ( - -
+
+

+ Entry ordering +

+
+ + ({ id: key }), + )} + strategy={verticalListSortingStrategy} + > + {storedConfig.dailyJournalAttributeOrder.map((attribute) => ( + + ))} + + +
+
+
+

+ Entry Grouping +

+ + + + +

Create Entry diff --git a/src/views/Settings/styles.module.css b/src/views/Settings/styles.module.css index c5b9727..8bf72c8 100644 --- a/src/views/Settings/styles.module.css +++ b/src/views/Settings/styles.module.css @@ -13,9 +13,7 @@ .container { display: flex; flex-direction: column; - background-color: var(--color-quaternary); - padding: var(--spacing-xs); - gap: var(--spacing-xs); + gap: var(--width-separator-lg); .work-item { background-color: var(--color-foreground); @@ -23,4 +21,41 @@ } } } + + + .attribute-list { + display: flex; + flex-direction: column; + gap: var(--width-separator-lg); + + &.dragging-over { + outline: var(--width-separator-md) solid var(--color-separator); + } + + .attribute { + display: flex; + align-items: center; + background-color: var(--color-foreground); + padding: var(--spacing-xs) var(--spacing-sm); + gap: var(--spacing-xs); + + &.dragging { + opacity: 0.8; + z-index: 1; + box-shadow: var(--box-shadow-md); + } + + .drag-handle { + cursor: grab; + } + + &:hover { + background-color: var(--color-tertiary); + } + + .label { + flex-grow: 1; + } + } + } } diff --git a/tsconfig.json b/tsconfig.json index e0371ce..e630182 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "paths": { "#generated/*": ["./generated/*"], + "#resources/*": ["./src/resources/*"], "#components/*": ["./src/components/*"], "#config": ["./src/config"], "#contexts/*": ["./src/contexts/*"],