diff --git a/package.json b/package.json index e44dba9..8d1b8d7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@urql/exchange-graphcache": "^7.1.2", "match-sorter": "^6.3.4", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-router-dom": "^6.18.0", @@ -47,6 +48,7 @@ "@julr/vite-plugin-validate-env": "^1.0.1", "@types/node": "^20.11.6", "@types/react": "^18.0.28", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.0.11", "@typescript-eslint/eslint-plugin": "^5.59.5", "@typescript-eslint/parser": "^5.59.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d4c289..058d49e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: react: specifier: ^18.2.0 version: 18.2.0 + react-beautiful-dnd: + specifier: ^13.1.1 + version: 13.1.1(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -82,6 +85,9 @@ devDependencies: '@types/react': specifier: ^18.0.28 version: 18.0.28 + '@types/react-beautiful-dnd': + specifier: ^13.1.8 + version: 13.1.8 '@types/react-dom': specifier: ^18.0.11 version: 18.0.11 @@ -3828,6 +3834,13 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true + /@types/hoist-non-react-statics@3.3.5: + resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==} + dependencies: + '@types/react': 18.0.28 + hoist-non-react-statics: 3.3.2 + dev: false + /@types/istanbul-lib-coverage@2.0.6: resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} dev: true @@ -3852,6 +3865,11 @@ packages: /@types/prop-types@15.7.12: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + + /@types/react-beautiful-dnd@13.1.8: + resolution: {integrity: sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==} + dependencies: + '@types/react': 18.0.28 dev: true /@types/react-dom@18.0.11: @@ -3860,13 +3878,21 @@ packages: '@types/react': 18.0.28 dev: true + /@types/react-redux@7.1.33: + resolution: {integrity: sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==} + dependencies: + '@types/hoist-non-react-statics': 3.3.5 + '@types/react': 18.0.28 + hoist-non-react-statics: 3.3.2 + redux: 4.2.1 + dev: false + /@types/react@18.0.28: resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==} dependencies: '@types/prop-types': 15.7.12 '@types/scheduler': 0.23.0 csstype: 3.1.3 - dev: true /@types/resolve@1.20.2: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3874,7 +3900,6 @@ packages: /@types/scheduler@0.23.0: resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} - dev: true /@types/semver@7.5.8: resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -5254,6 +5279,12 @@ packages: postcss-selector-parser: 6.1.1 dev: true + /css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + dependencies: + tiny-invariant: 1.3.3 + dev: false + /css-functions-list@3.2.2: resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==} engines: {node: '>=12 || >=16'} @@ -5315,7 +5346,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -7671,6 +7701,10 @@ packages: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} dev: true + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -7883,7 +7917,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: true /object-inspect@1.13.2: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} @@ -8745,7 +8778,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: true /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -8795,6 +8827,10 @@ packages: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} dev: true + /raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + dev: false + /randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -8811,6 +8847,25 @@ packages: strip-json-comments: 2.0.1 dev: true + /react-beautiful-dnd@13.1.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.24.8 + css-box-model: 1.2.1 + memoize-one: 5.2.1 + raf-schd: 4.0.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-redux: 7.2.9(react-dom@18.2.0)(react@18.2.0) + redux: 4.2.1 + use-memo-one: 1.1.3(react@18.2.0) + transitivePeerDependencies: + - react-native + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -8832,10 +8887,36 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: false + /react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} dev: true + /react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.24.8 + '@types/react-redux': 7.1.33 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 17.0.2 + dev: false + /react-router-dom@6.18.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw==} engines: {node: '>=14.0.0'} @@ -8882,6 +8963,12 @@ packages: picomatch: 2.3.1 dev: true + /redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + dependencies: + '@babel/runtime': 7.24.8 + dev: false + /regenerate-unicode-properties@10.1.1: resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==} engines: {node: '>=4'} @@ -9937,7 +10024,6 @@ packages: /tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - dev: true /tinybench@2.8.0: resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==} @@ -10306,6 +10392,14 @@ packages: wonka: 6.3.4 dev: false + /use-memo-one@1.1.3(react@18.2.0): + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true diff --git a/src/App/routes/PageError/index.tsx b/src/App/routes/PageError/index.tsx index f93df11..d1e7916 100644 --- a/src/App/routes/PageError/index.tsx +++ b/src/App/routes/PageError/index.tsx @@ -3,8 +3,15 @@ import { useEffect, useState, } from 'react'; +import { + IoCaretDown, + IoCaretUp, + IoHome, + IoReload, +} from 'react-icons/io5'; import { useRouteError } from 'react-router-dom'; +import Button from '#components/Button'; import Link from '#components/Link'; import styles from './styles.module.css'; @@ -24,14 +31,7 @@ function PageError() { const [ fullErrorVisible, setFullErrorVisible, - ] = useState(false); - - const handleErrorVisibleToggle = useCallback( - () => { - setFullErrorVisible((oldValue) => !oldValue); - }, - [setFullErrorVisible], - ); + ] = useState(import.meta.env.DEV); const handleReloadButtonClick = useCallback( () => { @@ -49,45 +49,53 @@ function PageError() {

Looks like we ran into some issue!

-
- {errorResponse?.error?.message - ?? errorResponse?.message - ?? 'Something unexpected happended!'} -
- + {!fullErrorVisible && ( +
+ {errorResponse?.error?.message + ?? errorResponse?.message + ?? 'Something unexpected happended!'} +
+ )} {fullErrorVisible && ( - <> -
- {errorResponse?.error?.stack - ?? errorResponse?.stack ?? 'Stack trace not available!'} -
-
- See the developer console for more details. -
- +
+ {errorResponse?.error?.stack + ?? errorResponse?.stack ?? 'Stack trace not available!'} +
)} +
+ See the developer console for more details. +
- {/* NOTE: using the anchor element as it will refresh the page */} - - Go back to homepage - - + {fullErrorVisible ? 'Hide details' : 'Show details'} + +
+ {/* NOTE: using the anchor element as it will refresh the page */} + } + variant="quaternary" + > + Go to homepage + + +
diff --git a/src/App/routes/PageError/styles.module.css b/src/App/routes/PageError/styles.module.css index c495c1f..818ec32 100644 --- a/src/App/routes/PageError/styles.module.css +++ b/src/App/routes/PageError/styles.module.css @@ -8,56 +8,54 @@ .container { display: flex; flex-direction: column; - /* - border-top: var(--go-ui-width-separator-large) solid var(--go-ui-color-primary-red); - border-radius: var(--go-ui-border-radius-xl); - box-shadow: var(--go-ui-box-shadow-2xl); - background-color: var(--go-ui-color-white); - padding: var(--go-ui-spacing-2xl); - width: calc(100% - var(--go-ui-spacing-2xl)); - */ + border-top: var(--width-separator-lg) solid var(--color-primary); + border-radius: var(--border-radius-xl); + box-shadow: var(--box-shadow-lg); + background-color: var(--color-foreground); + padding: var(--spacing-2xl); + width: calc(100% - var(--spacing-2xl)); max-width: 60rem; max-height: 60rem; - /* - gap: var(--go-ui-spacing-2xl); - */ + gap: var(--spacing-lg); .content { display: flex; flex-direction: column; - /* - gap: var(--go-ui-spacing-md); - */ + gap: var(--spacing-md); .heading { margin: 0; - /* - font-weight: var(--go-ui-font-weight-medium); - */ + font-weight: var(--font-weight-semibold); + } + + .message { + font-family: var(--font-family-mono); } .stack { flex-grow: 1; - /* - background-color: var(--go-ui-color-background); - padding: var(--go-ui-spacing-md); - */ + background-color: var(--color-background); + padding: var(--spacing-md); width: 100%; overflow: auto; white-space: pre; - /* - font-family: var(--go-ui-font-family-mono); - */ + font-family: var(--font-family-mono); } } .footer { display: flex; align-items: center; - justify-content: flex-end; - /* - gap: var(--go-ui-spacing-md); - */ + justify-content: space-between; + flex-wrap: wrap; + gap: var(--spacing-md); + + .actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-sm); + } } } } diff --git a/src/index.css b/src/index.css index 69b3d6a..7ace9af 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,7 @@ :root { --font-family-sans-serif: "Fira Sans", sans-serif; + --font-family-mono: "Oxygen Mono", monospace; --color-primary: #c45332; --color-secondary: #9cb56e; @@ -159,6 +160,26 @@ h1, h2, h3, h4, h5, h6 { font-weight: var(--font-weight-semibold); } +h1 { + font-size: var(--font-size-2xl); +} + +h2 { + font-size: var(--font-size-xl); +} + +h3 { + font-size: var(--font-size-lg); +} + +h4 { + font-size: var(--font-size-md); +} + +h5 { + font-size: var(--font-size-sm); +} + p { margin: 0; } diff --git a/src/utils/common.ts b/src/utils/common.ts index 3e0b7cf..c7e7c83 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -212,3 +212,119 @@ export function fuzzySearch( rows, ); } + +export function sortByAttributes( + list: LIST_ITEM[], + attributes: ATTRIBUTE[], + sortFn: (a: LIST_ITEM, b: LIST_ITEM, attr: ATTRIBUTE) => number, +): LIST_ITEM[] { + const newList = [...list]; + newList.sort( + (a, b) => { + let sortResult = 0; + + for (let i = 0; i < attributes.length; i += 1) { + const currentSortResult = sortFn( + a, + b, + attributes[i], + ); + + if (currentSortResult !== 0) { + sortResult = currentSortResult; + break; + } + } + + return sortResult; + }, + ); + + return newList; +} + +type GroupedItem = { + key: string; + type: 'heading'; + value: LIST_ITEM; + attribute: ATTRIBUTE; + level: number; +} | { + type: 'list-item'; + value: LIST_ITEM; +}; + +// NOTE: the list must be sorted before grouping +export function groupListByAttributes( + list: LIST_ITEM[], + attributes: ATTRIBUTE[], + compareItemAttributes: (a: LIST_ITEM, b: LIST_ITEM, attribute: ATTRIBUTE) => boolean, +): GroupedItem[] { + if (isNotDefined(list) || list.length === 0) { + return []; + } + + const groupedItems = list.flatMap((listItem, listIndex) => { + if (listIndex === 0) { + const headings = attributes.map((attribute, i) => ({ + type: 'heading' as const, + value: listItem, + attribute, + level: i, + key: `heading-${listIndex}-${i}`, + })); + + return [ + ...headings, + { + type: 'list-item' as const, + value: listItem, + }, + ]; + } + + const prevListItem = list[listIndex - 1]; + const attributeMismatchIndex = attributes.findIndex((attribute) => { + const hasSameCurrentAttribute = compareItemAttributes( + listItem, + prevListItem, + attribute, + ); + + return !hasSameCurrentAttribute; + }); + + if (attributeMismatchIndex === -1) { + return [ + { + type: 'list-item' as const, + value: listItem, + }, + ]; + } + + const headings = attributes.map((attribute, i) => { + if (i < attributeMismatchIndex) { + return undefined; + } + + return { + type: 'heading' as const, + value: listItem, + attribute, + level: i, + key: `heading-${listIndex}-${i}`, + }; + }).filter(isDefined); + + return [ + ...headings, + { + type: 'list-item' as const, + value: listItem, + }, + ]; + }); + + return groupedItems; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index ef93e69..0e798bd 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,4 +1,7 @@ -import { ConfigStorage } from './types'; +import { + ConfigStorage, + NumericOption, +} from './types'; export const KEY_CONFIG_STORAGE = 'timur-config'; @@ -11,6 +14,16 @@ export const defaultConfigValue: ConfigStorage = { showInputIcons: false, startSidebarShown: window.innerWidth >= 900, endSidebarShown: false, + dailyJournalGrouping: { + groupLevel: 2, + joinLevel: 2, + }, + dailyJournalAttributeOrder: [ + { key: 'project', sortDirection: 1 }, + { key: 'contract', sortDirection: 1 }, + { key: 'task', sortDirection: 1 }, + { key: 'status', sortDirection: 1 }, + ], }; export const colorscheme: [string, string][] = [ @@ -33,3 +46,16 @@ export const colorscheme: [string, string][] = [ // horchata 8 ['#7d5327', '#ecdecc'], ]; + +export const numericOptions: NumericOption[] = [ + { key: 1, label: '1' }, + { key: 2, label: '2' }, + { key: 3, label: '3' }, + { key: 4, label: '4' }, +]; +export function numericOptionKeySelector(option: NumericOption) { + return option.key; +} +export function numericOptionLabelSelector(option: NumericOption) { + return option.label; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 1f11e8f..5ee4a48 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -30,6 +30,17 @@ export interface Note { content: string | undefined; } +export type DailyJournalAttributeKeys = 'project' | 'contract' | 'task' | 'status'; +export interface DailyJournalAttributeOrder { + key: DailyJournalAttributeKeys; + sortDirection: number; +} + +export interface DailyJournalGrouping { + groupLevel: number; + joinLevel: number; +} + export type ConfigStorage = { defaultTaskType: WorkItemType | undefined, defaultTaskStatus: WorkItemStatus, @@ -39,6 +50,8 @@ export type ConfigStorage = { startSidebarShown: boolean, endSidebarShown: boolean, compactTextArea: boolean, + dailyJournalAttributeOrder: DailyJournalAttributeOrder[]; + dailyJournalGrouping: DailyJournalGrouping; } export interface GeneralEvent { @@ -50,3 +63,8 @@ export interface GeneralEvent { date: string; remainingDays: number; } + +export interface NumericOption { + key: number; + label: string; +} diff --git a/src/views/DailyJournal/DayView/index.tsx b/src/views/DailyJournal/DayView/index.tsx index a5fb25c..a750317 100644 --- a/src/views/DailyJournal/DayView/index.tsx +++ b/src/views/DailyJournal/DayView/index.tsx @@ -1,4 +1,5 @@ import { + Fragment, useCallback, useContext, useMemo, @@ -6,31 +7,33 @@ import { import { FcClock } from 'react-icons/fc'; import { _cs, + compareString, isDefined, isNotDefined, - listToGroupList, - mapToList, sum, } from '@togglecorp/fujs'; -import List from '#components/List'; +import DefaultMessage from '#components/DefaultMessage'; import EnumsContext from '#contexts/enums'; import useFormattedRelativeDate from '#hooks/useFormattedRelativeDate'; import useLocalStorage from '#hooks/useLocalStorage'; -import { getDurationString } from '#utils/common'; +import { + getDurationString, + groupListByAttributes, + sortByAttributes, +} from '#utils/common'; import { defaultConfigValue, KEY_CONFIG_STORAGE, } from '#utils/constants'; import { ConfigStorage, - Contract, + DailyJournalAttributeOrder, EntriesAsList, - Project, WorkItem, } from '#utils/types'; -import ProjectGroupedView, { Props as ProjectGroupedViewProps } from './ProjectGroupedView'; +import WorkItemRow from './ProjectGroupedView/ContractGroupedView/WorkItemRow'; import styles from './styles.module.css'; @@ -44,18 +47,6 @@ const dateFormatter = new Intl.DateTimeFormat( }, ); -interface ProjectGroupedWorkItem { - project: Project, - contracts: { - contract: Contract, - workItems: WorkItem[], - }[], -} - -function getId(item: ProjectGroupedWorkItem) { - return item.project.id; -} - interface Props { className?: string; workItems: WorkItem[] | undefined; @@ -85,54 +76,37 @@ function DayView(props: Props) { defaultConfigValue, ); - const groupedWorkItems = useMemo( - (): ProjectGroupedWorkItem[] | undefined => { - if (isNotDefined(workItems) || isNotDefined(taskById)) { - return undefined; - } + const getWorkItemAttribute = useCallback(( + item: WorkItem, + attr: DailyJournalAttributeOrder, + ) => { + if (attr.key === 'status') { + return item.status; + } - return mapToList(listToGroupList( - mapToList(listToGroupList( - workItems, - (workItem) => taskById[workItem.task].contract.id, - undefined, - (list) => ({ - contract: taskById[list[0].task].contract, - workItems: list, - }), - )), - (contractGrouped) => contractGrouped.contract.project.id, - undefined, - (list) => ({ - project: list[0].contract.project, - contracts: list, - }), - )); - }, - [workItems, taskById], - ); + if (isNotDefined(taskById)) { + return undefined; + } - type GroupedWorkItem = NonNullable<(typeof groupedWorkItems)>[number]; - - const rendererParams = useCallback( - (_: string, item: GroupedWorkItem): ProjectGroupedViewProps => ({ - contracts: item.contracts, - project: item.project, - onWorkItemClone, - onWorkItemChange, - onWorkItemDelete, - }), - [ - onWorkItemClone, - onWorkItemChange, - onWorkItemDelete, - ], - ); + const taskDetails = taskById[item.task]; - const formattedDate = dateFormatter.format(new Date(selectedDate)); + if (attr.key === 'task') { + return taskDetails.name; + } - const formattedRelativeDate = useFormattedRelativeDate(selectedDate); + if (attr.key === 'contract') { + return taskDetails.contract.name; + } + + if (attr.key === 'project') { + return taskDetails.contract.project.name; + } + return undefined; + }, [taskById]); + + const formattedDate = dateFormatter.format(new Date(selectedDate)); + const formattedRelativeDate = useFormattedRelativeDate(selectedDate); const totalHours = useMemo( () => { if (isDefined(workItems)) { @@ -144,6 +118,48 @@ function DayView(props: Props) { [workItems], ); + const { + dailyJournalAttributeOrder, + dailyJournalGrouping, + } = storedConfig; + + const { groupLevel, joinLevel } = dailyJournalGrouping; + + const groupedItems = useMemo(() => { + if (isNotDefined(taskById) || isNotDefined(workItems)) { + return []; + } + + const sortedWorkItems = sortByAttributes( + workItems, + dailyJournalAttributeOrder, + (a, b, attr) => ( + compareString( + getWorkItemAttribute(a, attr), + getWorkItemAttribute(b, attr), + attr.sortDirection, + ) + ), + ); + + return groupListByAttributes( + sortedWorkItems, + dailyJournalAttributeOrder.slice(0, groupLevel), + (a, b, attr) => { + const aValue = getWorkItemAttribute(a, attr); + const bValue = getWorkItemAttribute(b, attr); + + return aValue === bValue; + }, + ); + }, [ + taskById, + workItems, + getWorkItemAttribute, + dailyJournalAttributeOrder, + groupLevel, + ]); + return (
@@ -170,29 +186,90 @@ function DayView(props: Props) { )}
- - Click on - {' '} - Add entry - {' '} - to create a new entry. - - )} /> + {!errored && !loading && ( +
+ {groupedItems.map((groupedItem) => { + if (groupedItem.type === 'heading') { + const levelDiff = groupLevel - joinLevel; + + const headingText = getWorkItemAttribute( + groupedItem.value, + groupedItem.attribute, + ); + + if (groupedItem.level < levelDiff) { + return ( +

+ {headingText} +

+ ); + } + + if (groupedItem.level < (groupLevel - 1)) { + return null; + } + + return ( +

+ {dailyJournalAttributeOrder.map((attribute, i) => { + if (i >= groupLevel) { + return null; + } + + const currentLabel = getWorkItemAttribute( + groupedItem.value, + attribute, + ); + + if (i < (groupLevel - joinLevel)) { + return null; + } + + return ( + + {i > (groupLevel - joinLevel) && ( +
+ )} +
{currentLabel}
+ + ); + })} +

+ ); + } + + const taskDetails = taskById?.[groupedItem.value.task]; + + if (!taskDetails) { + return null; + } + + return ( + + ); + })} +
+ )}
); } diff --git a/src/views/DailyJournal/DayView/styles.module.css b/src/views/DailyJournal/DayView/styles.module.css index 13a4709..b4c8305 100644 --- a/src/views/DailyJournal/DayView/styles.module.css +++ b/src/views/DailyJournal/DayView/styles.module.css @@ -54,4 +54,31 @@ flex-direction: column; gap: var(--spacing-md); } + + .new-group { + display: flex; + flex-direction: column; + gap: var(--width-separator-md); + + .nested-heading { + margin-top: var(--spacing-md); + } + + .joined-heading { + display: flex; + align-items: baseline; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + margin-bottom: var(--spacing-2xs); + + .separator { + border: var(--width-separator-sm) solid var(--color-separator); + height: 1rem; + } + } + + .work-item { + background-color: var(--color-foreground); + } + } } diff --git a/src/views/DailyJournal/StartSidebar/index.tsx b/src/views/DailyJournal/StartSidebar/index.tsx index 23fd6d6..469ccec 100644 --- a/src/views/DailyJournal/StartSidebar/index.tsx +++ b/src/views/DailyJournal/StartSidebar/index.tsx @@ -1,19 +1,27 @@ import { useCallback, useContext, + useEffect, + useId, + useState, } from 'react'; import { - isDefined, - isFalsyString, - isNotDefined, - listToGroupList, - mapToList, -} from '@togglecorp/fujs'; - -import Button from '#components/Button'; + DragDropContext, + Draggable, + DraggableProvided, + DraggableStateSnapshot, + Droppable, + DroppableProps, + DroppableProvided, + DroppableStateSnapshot, + DropResult, +} from 'react-beautiful-dnd'; +import { MdDragIndicator } from 'react-icons/md'; +import { _cs } from '@togglecorp/fujs'; + import Checkbox from '#components/Checkbox'; -import Link from '#components/Link'; import MonthlyCalendar from '#components/MonthlyCalendar'; +import RadioInput from '#components/RadioInput'; import SelectInput from '#components/SelectInput'; import EnumsContext from '#contexts/enums'; import { EnumsQuery } from '#generated/types/graphql'; @@ -23,16 +31,42 @@ import { colorscheme, defaultConfigValue, KEY_CONFIG_STORAGE, + numericOptionKeySelector, + numericOptionLabelSelector, + numericOptions, } from '#utils/constants'; import { ConfigStorage, + DailyJournalAttributeKeys, + DailyJournalAttributeOrder, + DailyJournalGrouping, EditingMode, WorkItem, - WorkItemStatus, } from '#utils/types'; import styles from './styles.module.css'; +// TODO: Move to separate component +function StrictModeDroppable({ children, ...props }: DroppableProps) { + const [enabled, setEnabled] = useState(false); + useEffect(() => { + const animation = requestAnimationFrame(() => setEnabled(true)); + return () => { + cancelAnimationFrame(animation); + setEnabled(false); + }; + }, []); + if (!enabled) { + return null; + } + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {children} + + ); +} + type EditingOption = { key: EditingMode, label: string }; function editingOptionKeySelector(item: EditingOption) { return item.key; @@ -74,6 +108,13 @@ function defaultColorSelector(_: T, i: number): [string, string] { return colorscheme[i % colorscheme.length]; } +const dailyJournalAttributeDetails: Record = { + project: { label: 'Project' }, + contract: { label: 'Contract' }, + task: { label: 'Task' }, + status: { label: 'Status' }, +}; + interface Props { workItems: WorkItem[]; selectedDate: string; @@ -86,12 +127,10 @@ interface Props { function StartSidebar(props: Props) { const { calendarComponentRef, - workItems, selectedDate, setSelectedDate, } = props; - const { taskById } = useContext(EnumsContext); const { enums } = useContext(EnumsContext); const [storedConfig, setStoredConfig] = useLocalStorage( @@ -101,53 +140,100 @@ function StartSidebar(props: Props) { const setConfigFieldValue = useSetFieldValue(setStoredConfig); - const handleCopyTextButtonClick = useCallback( - () => { - function toSubItem(workItem: WorkItem) { - const description = workItem.description ?? '??'; - const status: WorkItemStatus = workItem.status ?? 'TODO'; - const task = taskById?.[workItem.task]?.name ?? '??'; - - return description - .split('\n') - .map((item, i) => ([ - i === 0 ? ' -' : ' ', - status !== 'DONE' ? `\`${status.toUpperCase()}\`` : undefined, - i === 0 ? `${task}: ${item}` : item, - ].filter(isDefined).join(' '))) - .join('\n'); - } - - if (isNotDefined(taskById)) { - return; - } - - const groupedWorkItems = mapToList(listToGroupList( - workItems, - (workItem) => taskById[workItem.task].contract.project.id, - undefined, - (list) => ({ - project: taskById?.[list[0].task].contract.project, - workItems: list, - }), - )); - - const text = groupedWorkItems.map((projectGrouped) => { - const { project, workItems: projectWorkItems } = projectGrouped; - - return `- ${project.name}\n${projectWorkItems.map((workItem) => toSubItem(workItem)).join('\n')}`; - }).join('\n'); - - if (isFalsyString(text)) { - return; - } - - window.navigator.clipboard.writeText(text); - }, - [workItems, taskById], + const date = new Date(selectedDate); + + const droppableId = useId(); + const handleDragEnd = useCallback((result: DropResult) => { + const oldAttributes = storedConfig.dailyJournalAttributeOrder + ?? defaultConfigValue.dailyJournalAttributeOrder; + + if (!result.destination) { + return; + } + + const newAttributes = [...oldAttributes]; + const [removedItem] = newAttributes.splice(result.source.index, 1); + newAttributes.splice(result.destination.index, 0, removedItem); + + setConfigFieldValue(newAttributes, 'dailyJournalAttributeOrder'); + }, [storedConfig.dailyJournalAttributeOrder, setConfigFieldValue]); + + const getDraggableChildren = useCallback( + ( + attribute: DailyJournalAttributeOrder, + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+
+ +
+
+ {dailyJournalAttributeDetails[attribute.key].label} +
+
+ ), + [], ); - const date = new Date(selectedDate); + const droppableChildren = useCallback( + (droppableProvided: DroppableProvided, droppableSnapshot: DroppableStateSnapshot) => ( +
+ {storedConfig.dailyJournalAttributeOrder.map((attribute, index) => ( + + {(draggableProvided, draggableSnapshot) => getDraggableChildren( + attribute, + draggableProvided, + draggableSnapshot, + )} + + ))} + {droppableProvided.placeholder} +
+ ), + [getDraggableChildren, storedConfig.dailyJournalAttributeOrder], + ); + + 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]); return (
-
- - Go to today - - +
+

Ordering

+ + + {droppableChildren} + + +
+
+

+ Grouping +

+ +

diff --git a/src/views/DailyJournal/StartSidebar/styles.module.css b/src/views/DailyJournal/StartSidebar/styles.module.css index 5d69438..1eea26d 100644 --- a/src/views/DailyJournal/StartSidebar/styles.module.css +++ b/src/views/DailyJournal/StartSidebar/styles.module.css @@ -10,6 +10,49 @@ gap: var(--spacing-sm); } + .attributes { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + + .attribute-list { + display: flex; + flex-direction: column; + gap: var(--width-separator-md); + + &.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); + + .drag-handle { + cursor: grab; + } + + &:hover { + background-color: var(--color-separator); + } + + .label { + flex-grow: 1; + } + } + } + } + + .grouping { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + } + + .quick-settings { display: flex; flex-direction: column; diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 98675ce..31a1dae 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -745,6 +745,12 @@ export function Component() { > Add entry + + Go to today +