Add Support for LabelV2, Add LabelModal and LabelView.

Add React Query
This commit is contained in:
Mo Tarbin 2024-11-23 20:23:59 -05:00
parent 5e590bfe9f
commit 42182371ff
18 changed files with 839 additions and 71 deletions

118
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "donetick", "name": "donetick",
"version": "0.1.72", "version": "0.1.78",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "donetick", "name": "donetick",
"version": "0.1.72", "version": "0.1.78",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
@ -23,7 +23,9 @@
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-query": "^3.39.3",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.21.1",
"react-transition-group": "^4.4.5",
"reactjs-social-login": "^2.6.3", "reactjs-social-login": "^2.6.3",
"vite-plugin-pwa": "^0.20.0" "vite-plugin-pwa": "^0.20.0"
}, },
@ -4142,6 +4144,15 @@
} }
] ]
}, },
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@ -4182,6 +4193,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.23.1", "version": "4.23.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz",
@ -4664,6 +4691,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -6360,6 +6393,12 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -6574,6 +6613,16 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/match-sorter": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz",
"integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.8",
"remove-accents": "0.5.0"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -6594,6 +6643,12 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==",
"license": "MIT"
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@ -6665,6 +6720,15 @@
"thenify-all": "^1.0.0" "thenify-all": "^1.0.0"
} }
}, },
"node_modules/nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==",
"license": "ISC",
"dependencies": {
"big-integer": "^1.6.16"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -6864,6 +6928,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==",
"license": "MIT"
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -7481,6 +7551,32 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/react-query": {
"version": "3.39.3",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz",
"integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.21.1", "version": "6.21.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.1.tgz",
@ -7515,6 +7611,7 @@
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.5.5", "@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1", "dom-helpers": "^5.0.1",
@ -7673,6 +7770,12 @@
"jsesc": "bin/jsesc" "jsesc": "bin/jsesc"
} }
}, },
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT"
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -7728,7 +7831,6 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@ -8857,6 +8959,16 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"node_modules/upath": { "node_modules/upath": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",

View file

@ -35,7 +35,9 @@
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-query": "^3.39.3",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.21.1",
"react-transition-group": "^4.4.5",
"reactjs-social-login": "^2.6.3", "reactjs-social-login": "^2.6.3",
"vite-plugin-pwa": "^0.20.0" "vite-plugin-pwa": "^0.20.0"
}, },

View file

@ -2,6 +2,7 @@ import NavBar from '@/views/components/NavBar'
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy' import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker' import Tracker from '@openreplay/tracker'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react' import { useRegisterSW } from 'virtual:pwa-register/react'
import { UserContext } from './contexts/UserContext' import { UserContext } from './contexts/UserContext'
@ -20,6 +21,8 @@ const remove = className => {
const intervalMS = 5 * 60 * 1000 // 5 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes
function App() { function App() {
const queryClient = new QueryClient()
startOpenReplay() startOpenReplay()
const { mode, systemMode } = useColorScheme() const { mode, systemMode } = useColorScheme()
@ -92,6 +95,7 @@ function App() {
return ( return (
<div className='min-h-screen'> <div className='min-h-screen'>
<QueryClientProvider client={queryClient}>
<AuthenticationProvider /> <AuthenticationProvider />
<UserContext.Provider value={{ userProfile, setUserProfile }}> <UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar /> <NavBar />
@ -114,6 +118,8 @@ function App() {
</Button> </Button>
</Snackbar> </Snackbar>
)} )}
</QueryClientProvider>
,
</div> </div>
) )
} }

View file

@ -12,6 +12,7 @@ import ChoreView from '../views/ChoreEdit/ChoreView'
import MyChores from '../views/Chores/MyChores' import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle' import JoinCircleView from '../views/Circles/JoinCircle'
import ChoreHistory from '../views/History/ChoreHistory' import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing' import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView' import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView' import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
@ -116,6 +117,10 @@ const Router = createBrowserRouter([
path: 'things/:id', path: 'things/:id',
element: <ThingsHistory />, element: <ThingsHistory />,
}, },
{
path: 'labels/',
element: <LabelView />,
},
], ],
}, },
]) ])

View file

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery } from 'react-query'
import { CreateChore, GetChoresNew } from '../utils/Fetcher'
export const useChores = () => {
return useQuery('chores', GetChoresNew)
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
return useMutation(CreateChore, {
onSuccess: () => {
queryClient.invalidateQueries('chores')
},
})
}

View file

@ -0,0 +1,6 @@
import { useQuery } from 'react-query'
import { GetAllUsers } from '../utils/Fetcher'
export const useAllUsers = () => {
return useQuery('allUsers', GetAllUsers)
}

View file

@ -45,6 +45,13 @@ const GetAllUsers = () => {
headers: HEADERS(), headers: HEADERS(),
}) })
} }
const GetChoresNew = async () => {
const resp = await Fetch(`${API_URL}/chores/`, {
method: 'GET',
headers: HEADERS(),
})
return resp.json()
}
const GetChores = () => { const GetChores = () => {
return Fetch(`${API_URL}/chores/`, { return Fetch(`${API_URL}/chores/`, {
@ -294,16 +301,49 @@ const GetLongLiveTokens = () => {
headers: HEADERS(), headers: HEADERS(),
}) })
} }
const CreateLabel = label => {
return Fetch(`${API_URL}/labels`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(label),
})
}
const GetLabels = async () => {
const resp = await Fetch(`${API_URL}/labels`, {
method: 'GET',
headers: HEADERS(),
})
return resp.json()
}
const UpdateLabel = label => {
return Fetch(`${API_URL}/labels`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(label),
})
}
const DeleteLabel = id => {
return Fetch(`${API_URL}/labels/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
export { export {
AcceptCircleMemberRequest, AcceptCircleMemberRequest,
CancelSubscription, CancelSubscription,
createChore, createChore,
CreateChore, CreateChore,
CreateLabel,
CreateLongLiveToken, CreateLongLiveToken,
CreateThing, CreateThing,
DeleteChore, DeleteChore,
DeleteChoreHistory, DeleteChoreHistory,
DeleteCircleMember, DeleteCircleMember,
DeleteLabel,
DeleteLongLiveToken, DeleteLongLiveToken,
DeleteThing, DeleteThing,
GetAllCircleMembers, GetAllCircleMembers,
@ -312,7 +352,9 @@ export {
GetChoreDetailById, GetChoreDetailById,
GetChoreHistory, GetChoreHistory,
GetChores, GetChores,
GetChoresNew,
GetCircleMemberRequests, GetCircleMemberRequests,
GetLabels,
GetLongLiveTokens, GetLongLiveTokens,
GetSubscriptionSession, GetSubscriptionSession,
GetThingHistory, GetThingHistory,
@ -330,6 +372,7 @@ export {
UpdateChoreAssignee, UpdateChoreAssignee,
UpdateChoreHistory, UpdateChoreHistory,
UpdateChorePriority, UpdateChorePriority,
UpdateLabel,
UpdatePassword, UpdatePassword,
UpdateThingState, UpdateThingState,
UpdateUserDetails, UpdateUserDetails,

38
src/utils/LabelColors.jsx Normal file
View file

@ -0,0 +1,38 @@
const LABEL_COLORS = [
{ name: 'Default', value: '#FFFFFF' },
{ name: 'Salmon', value: '#ff7961' },
{ name: 'Teal', value: '#26a69a' },
{ name: 'Sky Blue', value: '#80d8ff' },
{ name: 'Grape', value: '#7e57c2' },
{ name: 'Sunshine', value: '#ffee58' },
{ name: 'Coral', value: '#ff7043' },
{ name: 'Lavender', value: '#ce93d8' },
{ name: 'Rose', value: '#f48fb1' },
{ name: 'Charcoal', value: '#616161' },
{ name: 'Sienna', value: '#8d6e63' },
{ name: 'Mint', value: '#a7ffeb' },
{ name: 'Amber', value: '#ffc107' },
{ name: 'Cobalt', value: '#3f51b5' },
{ name: 'Emerald', value: '#4caf50' },
{ name: 'Peach', value: '#ffab91' },
{ name: 'Ocean', value: '#0288d1' },
{ name: 'Mustard', value: '#ffca28' },
{ name: 'Ruby', value: '#d32f2f' },
{ name: 'Periwinkle', value: '#b39ddb' },
{ name: 'Turquoise', value: '#00bcd4' },
{ name: 'Lime', value: '#cddc39' },
{ name: 'Blush', value: '#f8bbd0' },
{ name: 'Ash', value: '#90a4ae' },
{ name: 'Sand', value: '#d7ccc8' },
]
export default LABEL_COLORS
export const getTextColorFromBackgroundColor = bgColor => {
if (!bgColor) return ''
const hex = bgColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff'
}

View file

@ -1,3 +1,4 @@
import { Add } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@ -11,6 +12,7 @@ import {
Input, Input,
List, List,
ListItem, ListItem,
MenuItem,
Option, Option,
Radio, Radio,
RadioGroup, RadioGroup,
@ -34,8 +36,10 @@ import {
SaveChore, SaveChore,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import FreeSoloCreateOption from '../components/AutocompleteSelect' import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import RepeatSection from './RepeatSection' import RepeatSection from './RepeatSection'
const ASSIGN_STRATEGIES = [ const ASSIGN_STRATEGIES = [
'random', 'random',
@ -66,6 +70,7 @@ const ChoreEdit = () => {
const [frequency, setFrequency] = useState(1) const [frequency, setFrequency] = useState(1)
const [frequencyMetadata, setFrequencyMetadata] = useState({}) const [frequencyMetadata, setFrequencyMetadata] = useState({})
const [labels, setLabels] = useState([]) const [labels, setLabels] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [allUserThings, setAllUserThings] = useState([]) const [allUserThings, setAllUserThings] = useState([])
const [thingTrigger, setThingTrigger] = useState(null) const [thingTrigger, setThingTrigger] = useState(null)
const [isThingValid, setIsThingValid] = useState(false) const [isThingValid, setIsThingValid] = useState(false)
@ -82,6 +87,9 @@ const ChoreEdit = () => {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false) const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('') const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarColor, setSnackbarColor] = useState('warning') const [snackbarColor, setSnackbarColor] = useState('warning')
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const { data: userLabels, isLoading: isUserLabelsLoading } = useLabels()
const Navigate = useNavigate() const Navigate = useNavigate()
const HandleValidateChore = () => { const HandleValidateChore = () => {
@ -172,7 +180,8 @@ const ChoreEdit = () => {
isRolling: isRolling, isRolling: isRolling,
isActive: isActive, isActive: isActive,
notification: isNotificable, notification: isNotificable,
labels: labels, labels: labels.map(l => l.name),
labelsV2: labelsV2,
notificationMetadata: notificationMetadata, notificationMetadata: notificationMetadata,
thingTrigger: thingTrigger, thingTrigger: thingTrigger,
} }
@ -226,8 +235,9 @@ const ChoreEdit = () => {
setFrequency(data.res.frequency) setFrequency(data.res.frequency)
setNotificationMetadata(JSON.parse(data.res.notificationMetadata)) setNotificationMetadata(JSON.parse(data.res.notificationMetadata))
setLabels(data.res.labels ? data.res.labels.split(',') : []) // setLabels(data.res.labels ? data.res.labels.split(',') : [])
setLabelsV2(data.res.labelsV2)
setAssignStrategy( setAssignStrategy(
data.res.assignStrategy data.res.assignStrategy
? data.res.assignStrategy ? data.res.assignStrategy
@ -275,6 +285,14 @@ const ChoreEdit = () => {
} }
}, []) }, [])
// useEffect(() => {
// if (userLabels && userLabels.length == 0 && labelsV2.length == 0) {
// return
// }
// const labelIds = labelsV2.map(l => l.id)
// setLabelsV2(userLabels.filter(l => labelIds.indexOf(l.id) > -1))
// }, [userLabels, labelsV2])
useEffect(() => { useEffect(() => {
// if frequancy type change to somthing need a due date then set it to the current date: // if frequancy type change to somthing need a due date then set it to the current date:
if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) { if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
@ -329,6 +347,7 @@ const ChoreEdit = () => {
}, },
}) })
} }
return ( return (
<Container maxWidth='md'> <Container maxWidth='md'>
{/* <Typography level='h3' mb={1.5}> {/* <Typography level='h3' mb={1.5}>
@ -726,8 +745,9 @@ const ChoreEdit = () => {
<Typography level='h5'> <Typography level='h5'>
Things to remember about this chore or to tag it Things to remember about this chore or to tag it
</Typography> </Typography>
<FreeSoloCreateOption {/* <FreeSoloCreateOption
options={labels} options={[...labels, 'test']}
selected={labels}
onSelectChange={changes => { onSelectChange={changes => {
const newLabels = [] const newLabels = []
changes.map(change => { changes.map(change => {
@ -741,7 +761,99 @@ const ChoreEdit = () => {
}) })
setLabels(newLabels) setLabels(newLabels)
}} }}
/> */}
<Select
multiple
onChange={(event, newValue) => {
setLabelsV2(userLabels.filter(l => newValue.indexOf(l.name) > -1))
}}
value={labelsV2.map(l => l.name)}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: '0.25rem' }}>
{labelsV2.map(selectedOption => {
return (
<Chip
variant='soft'
color='primary'
key={selectedOption.id}
size='lg'
sx={{
background: selectedOption.color,
color: getTextColorFromBackgroundColor(
selectedOption.color,
),
}}
>
{selectedOption.name}
</Chip>
)
})}
</Box>
)}
sx={{ minWidth: '15rem' }}
slotProps={{
listbox: {
sx: {
width: '100%',
},
},
}}
>
{userLabels &&
userLabels
// .map(l => l.name)
.map(label => (
<Option key={label.id + label.name} value={label.name}>
<div
style={{
width: '20 px',
height: '20 px',
borderRadius: '50%',
background: label.color,
}}
/> />
{label.name}
</Option>
))}
<MenuItem
key={'addNewLabel'}
value={' New Label'}
onClick={() => {
setAddLabelModalOpen(true)
}}
>
<Add />
Add New Label
</MenuItem>
</Select>
{/* <Card>
<List
orientation='horizontal'
wrap
sx={{
'--List-gap': '8px',
'--ListItem-radius': '20px',
}}
>
{labels?.map((label, index) => (
<ListItem key={label}>
<Chip
onClick={() => {
setLabels(labels.filter(l => l !== label))
}}
checked={true}
overlay
variant='soft'
color='primary'
size='lg'
endDecorator={<Cancel />}
>
{label}
</Chip>
</ListItem>
))}
</List>
</Card> */}
</Box> </Box>
{choreId > 0 && ( {choreId > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
@ -822,6 +934,16 @@ const ChoreEdit = () => {
</Button> </Button>
</Sheet> </Sheet>
<ConfirmationModal config={confirmModelConfig} /> <ConfirmationModal config={confirmModelConfig} />
{addLabelModalOpen && (
<LabelModal
isOpen={addLabelModalOpen}
onSave={label => {
setLabels([...labels, label])
setAddLabelModalOpen(false)
}}
onClose={() => setAddLabelModalOpen(false)}
/>
)}
{/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */} {/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
<Snackbar <Snackbar
open={isSnackbarOpen} open={isSnackbarOpen}

View file

@ -43,6 +43,7 @@ import {
SkipChore, SkipChore,
UpdateChorePriority, UpdateChorePriority,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import Priorities from '../../utils/Priorities' import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const IconCard = styled('div')({ const IconCard = styled('div')({
@ -264,6 +265,22 @@ const ChoreView = () => {
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}` ? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
: 'N/A'} : 'N/A'}
</Chip> </Chip>
{/* show each label : */}
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
>
{label?.name}
</Chip>
))}
</Box> </Box>
<Box> <Box>
<Sheet <Sheet

View file

@ -47,6 +47,7 @@ import {
SkipChore, SkipChore,
UpdateChoreAssignee, UpdateChoreAssignee,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import { Fetch } from '../../utils/TokenManager' import { Fetch } from '../../utils/TokenManager'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal' import DateModal from '../Modals/Inputs/DateModal'
@ -58,6 +59,7 @@ const ChoreCard = ({
performers, performers,
onChoreUpdate, onChoreUpdate,
onChoreRemove, onChoreRemove,
userLabels,
sx, sx,
viewOnly, viewOnly,
}) => { }) => {
@ -407,7 +409,7 @@ const ChoreCard = ({
} }
return ( return (
<> <Box key={chore.id + '-box'}>
<Chip <Chip
variant='soft' variant='soft'
sx={{ sx={{
@ -455,6 +457,7 @@ const ChoreCard = ({
// backgroundColor: 'white', // backgroundColor: 'white',
boxShadow: 'sm', boxShadow: 'sm',
borderRadius: 20, borderRadius: 20,
key: `${chore.id}-card`,
// mb: 2, // mb: 2,
}} }}
@ -485,7 +488,7 @@ const ChoreCard = ({
</Chip> </Chip>
</Typography> </Typography>
)} )}
<Box> <Box key={`${chore.id}-labels`}>
{chore.priority > 0 && ( {chore.priority > 0 && (
<Chip <Chip
sx={{ sx={{
@ -505,22 +508,27 @@ const ChoreCard = ({
P{chore.priority} P{chore.priority}
</Chip> </Chip>
)} )}
{chore.labels?.split(',').map((label, index) => ( {chore.labelsV2?.map((l, index) => {
return (
<Chip <Chip
variant='solid' variant='solid'
key={label} key={l.id}
color='primary' color='primary'
sx={{ sx={{
position: 'relative', position: 'relative',
ml: index === 0 ? 0 : 0.5, ml: index === 0 ? 0 : 0.5,
top: 2, top: 2,
zIndex: 1, zIndex: 1,
backgroundColor: l?.color,
color: getTextColorFromBackgroundColor(l?.color),
}} }}
startDecorator={getIconForLabel(label)}
// startDecorator={getIconForLabel(label)}
> >
{label} {l?.name}
</Chip> </Chip>
))} )
})}
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -757,7 +765,7 @@ const ChoreCard = ({
</Typography> </Typography>
</Snackbar> </Snackbar>
</Card> </Card>
</> </Box>
) )
} }

View file

@ -21,8 +21,10 @@ import Fuse from 'fuse.js'
import { useContext, useEffect, useRef, useState } from 'react' import { useContext, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext' import { UserContext } from '../../contexts/UserContext'
import { GetAllUsers, GetChores, GetUserProfile } from '../../utils/Fetcher' import { useChores } from '../../queries/ChoreQueries'
import { GetAllUsers, GetUserProfile } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
const MyChores = () => { const MyChores = () => {
@ -38,6 +40,8 @@ const MyChores = () => {
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null) const menuRef = useRef(null)
const Navigate = useNavigate() const Navigate = useNavigate()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: choresData, isLoading: choresLoading } = useChores()
const choreSorter = (a, b) => { const choreSorter = (a, b) => {
// 1. Handle null due dates (always last): // 1. Handle null due dates (always last):
if (!a.nextDueDate && !b.nextDueDate) return 0 // Both null, no order if (!a.nextDueDate && !b.nextDueDate) return 0 // Both null, no order
@ -74,14 +78,6 @@ const MyChores = () => {
setUserProfile(data.res) setUserProfile(data.res)
}) })
} }
GetChores()
.then(response => response.json())
.then(data => {
data.res.sort(choreSorter)
setChores(data.res)
setFilteredChores(data.res)
})
GetAllUsers() GetAllUsers()
.then(response => response.json()) .then(response => response.json())
@ -94,6 +90,15 @@ const MyChores = () => {
setActiveUserId(currentUser.id) setActiveUserId(currentUser.id)
} }
}, []) }, [])
useEffect(() => {
if (choresData) {
const sortedChores = choresData.res.sort(choreSorter)
setChores(sortedChores)
setFilteredChores(sortedChores)
}
}, [choresData, choresLoading])
useEffect(() => { useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick) document.addEventListener('mousedown', handleMenuOutsideClick)
return () => { return () => {
@ -160,12 +165,21 @@ const MyChores = () => {
const searchOptions = { const searchOptions = {
// keys to search in // keys to search in
keys: ['name', 'labels'], keys: ['name', 'raw_label'],
includeScore: true, // Optional: if you want to see how well each result matched the search term includeScore: true, // Optional: if you want to see how well each result matched the search term
isCaseSensitive: false, isCaseSensitive: false,
findAllMatches: true, findAllMatches: true,
} }
const fuse = new Fuse(chores, searchOptions)
const fuse = new Fuse(
chores.map(c => ({
...c,
raw_label: c.labelsV2
.map(l => userLabels.find(x => (x.id = l.id)).name)
.join(' '),
})),
searchOptions,
)
const handleSearchChange = e => { const handleSearchChange = e => {
const search = e.target.value const search = e.target.value
@ -180,7 +194,12 @@ const MyChores = () => {
setFilteredChores(fuse.search(term).map(result => result.item)) setFilteredChores(fuse.search(term).map(result => result.item))
} }
if (userProfile === null) { if (
userProfile === null ||
userLabelsLoading ||
performers.length === 0 ||
choresLoading
) {
return <LoadingComponent /> return <LoadingComponent />
} }
@ -326,6 +345,7 @@ const MyChores = () => {
onChoreUpdate={handleChoreUpdated} onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted} onChoreRemove={handleChoreDeleted}
performers={performers} performers={performers}
userLabels={userLabels}
/> />
))} ))}

View file

@ -0,0 +1,8 @@
import { useQuery } from 'react-query'
import { GetLabels } from '../../utils/Fetcher'
export const useLabels = () => {
return useQuery('labels', GetLabels, {
initialData: [],
})
}

View file

@ -0,0 +1,178 @@
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Box,
CircularProgress,
Container,
IconButton,
Table,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries'
// import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Add } from '@mui/icons-material'
import { useQueryClient } from 'react-query'
import { DeleteLabel } from '../../utils/Fetcher'
const LabelView = () => {
const { data: labels, isLabelsLoading, isError } = useLabels()
const [userLabels, setUserLabels] = useState([labels])
const [modalOpen, setModalOpen] = useState(false)
const [currentLabel, setCurrentLabel] = useState(null) // Label being edited or null for new label
const queryClient = useQueryClient()
const handleAddLabel = () => {
setCurrentLabel(null) // Adding a new label
setModalOpen(true)
}
const handleEditLabel = label => {
setCurrentLabel(label) // Editing an existing label
setModalOpen(true)
}
const handleDeleteLabel = id => {
DeleteLabel(id).then(res => {
// Invalidate and refetch labels after deleting a label
const updatedLabels = userLabels.filter(label => label.id !== id)
setUserLabels(updatedLabels)
queryClient.invalidateQueries('labels')
})
// Implement deletion logic here
}
const handleSaveLabel = newOrUpdatedLabel => {
queryClient.invalidateQueries('labels')
setModalOpen(false)
const updatedLabels = userLabels.map(label =>
label.id === newOrUpdatedLabel.id ? newOrUpdatedLabel : label,
)
setUserLabels(updatedLabels)
}
useEffect(() => {
if (labels) {
setUserLabels(labels)
}
}, [labels])
if (isLabelsLoading) {
return (
<Box
display='flex'
justifyContent='center'
alignItems='center'
height='100vh'
>
<CircularProgress />
</Box>
)
}
if (isError) {
return (
<Typography color='danger' textAlign='center'>
Failed to load labels. Please try again.
</Typography>
)
}
return (
<Container maxWidth='md'>
<Table aria-label='Manage Labels' stickyHeader hoverRow>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Label</th>
<th style={{ textAlign: 'center' }}>Color</th>
<th style={{ textAlign: 'center' }}>Actions</th>
</tr>
</thead>
<tbody>
{userLabels.map(label => (
<tr key={label.id}>
<td>{label.name}</td>
<td
style={{
// center without display flex:
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
width={20}
height={20}
borderRadius='50%'
sx={{
backgroundColor: label.color,
}}
/>
</td>
<td
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<IconButton onClick={() => handleEditLabel(label)}>
<EditIcon />
</IconButton>
<IconButton
onClick={() => handleDeleteLabel(label.id)}
color='danger'
>
<DeleteIcon />
</IconButton>
</td>
</tr>
))}
</tbody>
</Table>
{userLabels.length === 0 && (
<Typography textAlign='center' mt={2}>
No labels available. Add a new label to get started.
</Typography>
)}
{modalOpen && (
<LabelModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveLabel}
label={currentLabel}
/>
)}
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<IconButton
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={handleAddLabel}
>
<Add />
</IconButton>
</Box>
</Container>
)
}
export default LabelView

View file

View file

@ -0,0 +1,175 @@
import {
Box,
Button,
FormControl,
Input,
Modal,
ModalDialog,
Option,
Select,
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
import { useQueryClient } from 'react-query'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import LABEL_COLORS from '../../../utils/LabelColors'
import { useLabels } from '../../Labels/LabelQueries'
function LabelModal({ isOpen, onClose, onSave, label }) {
const [labelName, setLabelName] = React.useState('')
const [color, setColor] = React.useState('')
const [error, setError] = React.useState('')
const { data: userLabels, isLoadingLabels } = useLabels()
const queryClient = useQueryClient()
// Populate the form fields when editing
useEffect(() => {
if (label) {
setLabelName(label.name)
setColor(label.color)
} else {
setLabelName('')
setColor('')
}
setError('')
}, [label])
const validateLabel = () => {
if (!labelName || labelName.trim() === '') {
setError('Name cannot be empty')
return false
} else if (
!label ||
userLabels.some(
userLabel => userLabel.name === labelName && userLabel.id !== label.id,
)
) {
setError('Label with this name already exists')
return false
} else if (color === '') {
setError('Please select a color')
return false
}
return true
}
const handleSave = () => {
if (!validateLabel()) {
return
}
const saveAction = label
? UpdateLabel({ id: label.id, name: labelName, color })
: CreateLabel({ name: labelName, color })
saveAction.then(res => {
if (res.error) {
console.log(res.error)
setError('Failed to save label. Please try again.')
return
}
queryClient.invalidateQueries('labels').then(() => {
onSave({ id: label?.id, name: labelName, color })
onClose()
})
})
}
return (
<Modal open={isOpen} onClose={onClose}>
<ModalDialog>
<Typography level='title-md' mb={1}>
{label ? 'Edit Label' : 'Add Label'}
</Typography>
<FormControl>
<Typography level='body-sm' alignSelf={'start'}>
Name
</Typography>
<Input
margin='normal'
required
fullWidth
name='labelName'
type='text'
id='labelName'
value={labelName}
onChange={e => setLabelName(e.target.value)}
/>
</FormControl>
{/* Color Selection */}
<FormControl>
<Typography level='body-sm' alignSelf={'start'}>
Color:
</Typography>
<Select
label='Color'
value={color}
renderValue={selected => (
<Typography
key={selected.value}
startDecorator={
<Box
className='h-4 w-4'
borderRadius={10}
sx={{
background: selected.value,
shadow: { xs: 1 },
}}
/>
}
>
{selected.label}
</Typography>
)}
onChange={(e, value) => {
value && setColor(value)
}}
>
{LABEL_COLORS.map(val => (
<Option key={val.value} value={val.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{
background: val.value,
}}
/>
<Typography
sx={{
ml: 1,
color: 'text.secondary',
}}
variant='caption'
>
{val.name}
</Typography>
</Box>
</Option>
))}
</Select>
{error && (
<Typography color='warning' level='body-sm'>
{error}
</Typography>
)}
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{label ? 'Save Changes' : 'Add Label'}
</Button>
<Button onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
)
}
export default LabelModal

View file

@ -7,14 +7,18 @@ import * as React from 'react'
const filter = createFilterOptions() const filter = createFilterOptions()
export default function FreeSoloCreateOption({ options, onSelectChange }) { export default function FreeSoloCreateOption({
options,
onSelectChange,
selected,
}) {
React.useEffect(() => { React.useEffect(() => {
setValue(options) setValue(options)
}, [options]) }, [options])
const [value, setValue] = React.useState([]) const [value, setValue] = React.useState([selected])
const [selectOptions, setSelectOptions] = React.useState( const [selectOptions, setSelectOptions] = React.useState(
options ? options : [], selected ? selected : [],
) )
return ( return (
<FormControl id='free-solo-with-text-demo'> <FormControl id='free-solo-with-text-demo'>
@ -38,26 +42,27 @@ export default function FreeSoloCreateOption({ options, onSelectChange }) {
} }
onSelectChange(newValue) onSelectChange(newValue)
}} }}
filterOptions={(options, params) => { filterOptions={(selected, params) => {
const filtered = filter(options, params) const filtered = filter(selected, params)
const { inputValue } = params const { inputValue } = params
// Suggest the creation of a new value // Suggest the creation of a new value
const isExisting = options.some(option => inputValue === option.title) const isExisting = selected.some(
option => inputValue === option.title,
)
if (inputValue !== '' && !isExisting) { if (inputValue !== '' && !isExisting) {
filtered.push({ filtered.push({
inputValue, inputValue,
title: `Add "${inputValue}"`, title: `Add "${inputValue}"`,
}) })
} }
return filtered return filtered
}} }}
selectOnFocus selectOnFocus
clearOnBlur clearOnBlur
handleHomeEndKeys handleHomeEndKeys
// freeSolo // freeSolo
options={selectOptions} options={options}
getOptionLabel={option => { getOptionLabel={option => {
// Value selected with enter, right from the input // Value selected with enter, right from the input
if (typeof option === 'string') { if (typeof option === 'string') {

View file

@ -2,7 +2,7 @@ import Logo from '@/assets/logo.svg'
import { import {
AccountBox, AccountBox,
HomeOutlined, HomeOutlined,
ListAltRounded, ListAlt,
Logout, Logout,
MenuRounded, MenuRounded,
Message, Message,
@ -30,6 +30,7 @@ const links = [
label: 'Home', label: 'Home',
icon: <HomeOutlined />, icon: <HomeOutlined />,
}, },
// { // {
// to: '/chores', // to: '/chores',
// label: 'Desktop View', // label: 'Desktop View',
@ -40,6 +41,11 @@ const links = [
label: 'Things', label: 'Things',
icon: <Widgets />, icon: <Widgets />,
}, },
{
to: 'labels',
label: 'Labels',
icon: <ListAlt />,
},
{ {
to: '/settings#sharing', to: '/settings#sharing',
label: 'Sharing', label: 'Sharing',