From 0622da04808e1724614861f1e0386208934698f0 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Thu, 25 Apr 2024 17:12:12 +0200 Subject: [PATCH 1/7] feat: allow editing of project details including accounts --- .gitignore | 2 + api/ContextAccounts.tsx | 140 + api/ContextProjects.tsx | 132 +- api/useAccount.ts | 34 - api/useMeeting.ts | 18 +- components/dayplan/dayplan-form.tsx | 2 +- .../ui-elements/DateSelector.module.css | 19 +- components/ui-elements/account-selector.tsx | 91 + components/ui-elements/activity-meta-data.tsx | 2 +- components/ui-elements/date-selector.tsx | 47 +- .../notes-writer/NotesWriter.module.css | 5 + .../ui-elements/notes-writer/NotesWriter.tsx | 5 +- .../project-details/ProjectDetails.module.css | 8 + .../project-details/next-actions.tsx | 25 +- .../project-details/project-dates.tsx | 46 +- .../project-details/project-details.tsx | 56 +- .../project-updates-helpers.ts | 49 + .../ui-elements/tokens/account-name.tsx | 29 +- package-lock.json | 4811 +++++++---------- package.json | 6 +- pages/_app.tsx | 5 +- pages/accounts/[id].tsx | 23 +- pages/meetings/[id].tsx | 4 +- pages/meetings/index.tsx | 2 +- pages/projects/[id].tsx | 30 +- pages/projects/index.tsx | 35 + scripts/compareJsonFiles.js | 6 +- scripts/parseJSON.js | 66 + 28 files changed, 2577 insertions(+), 3121 deletions(-) create mode 100644 api/ContextAccounts.tsx delete mode 100644 api/useAccount.ts create mode 100644 components/ui-elements/account-selector.tsx create mode 100644 components/ui-elements/project-details/project-updates-helpers.ts create mode 100644 pages/projects/index.tsx create mode 100644 scripts/parseJSON.js diff --git a/.gitignore b/.gitignore index fea90efc1..e73883300 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ next-env.d.ts amplifyconfiguration* components/import-data/* !components/import-data/imports-aws-sdk + +scripts/temp diff --git a/api/ContextAccounts.tsx b/api/ContextAccounts.tsx new file mode 100644 index 000000000..d6e47a76f --- /dev/null +++ b/api/ContextAccounts.tsx @@ -0,0 +1,140 @@ +import { FC, ReactNode, createContext, useContext } from "react"; +import { type Schema } from "@/amplify/data/resource"; +import { SelectionSet, generateClient } from "aws-amplify/data"; +import useSWR from "swr"; +import { handleApiErrors } from "./globals"; +const client = generateClient(); + +interface AccountsContextType { + accounts: Account[] | undefined; + errorAccounts: any; + loadingAccounts: boolean; + createAccount: ( + accountName: string + ) => Promise; + getAccountById: (accountId: string) => Account | undefined; + saveAccountName: ( + accountId: string, + accountName: string + ) => Promise; +} + +export type Account = { + id: string; + name: string; + introduction: string; + controllerId?: string; +}; + +const selectionSet = [ + "id", + "name", + "accountSubsidiariesId", + "introduction", +] as const; + +type AccountData = SelectionSet; + +export const mapAccount: (account: AccountData) => Account = ({ + id, + name, + accountSubsidiariesId, + introduction, +}) => ({ + id, + name, + introduction: introduction || "", + controllerId: accountSubsidiariesId || undefined, +}); + +const fetchAccounts = async () => { + const { data, errors } = await client.models.Account.list({ + limit: 500, + selectionSet, + }); + if (errors) throw errors; + return data.map(mapAccount); +}; + +interface AccountsContextProviderProps { + children: ReactNode; +} + +export const AccountsContextProvider: FC = ({ + children, +}) => { + const { + data: accounts, + error: errorAccounts, + isLoading: loadingAccounts, + mutate, + } = useSWR(`/api/accounts/`, fetchAccounts); + + const createAccount = async ( + accountName: string + ): Promise => { + if (accountName.length < 3) return; + + const newAccount: Account = { + id: crypto.randomUUID(), + name: accountName, + introduction: "", + }; + + const updatedAccounts = [...(accounts || []), newAccount]; + mutate(updatedAccounts, false); + + const { data, errors } = await client.models.Account.create({ + name: accountName, + }); + if (errors) handleApiErrors(errors, "Error creating account"); + mutate(updatedAccounts); + return data; + }; + + const getAccountById = (accountId: string) => + accounts?.find((account) => account.id === accountId); + + const saveAccountName = async (accountId: string, accountName: string) => { + const updated: Account[] = + accounts?.map((a) => + a.id !== accountId ? a : { ...a, name: accountName } + ) || []; + mutate(updated, false); + const { data, errors } = await client.models.Account.update({ + id: accountId, + name: accountName, + }); + if (errors) handleApiErrors(errors, "Error updating account"); + mutate(updated); + return data?.id; + }; + + return ( + + {children} + + ); +}; + +const AccountsContext = createContext( + undefined +); + +export const useAccountsContext = () => { + const accounts = useContext(AccountsContext); + if (!accounts) + throw new Error( + "useAccountsContext must be used within AccountsContextProvider" + ); + return accounts; +}; diff --git a/api/ContextProjects.tsx b/api/ContextProjects.tsx index 0a7c39377..b1cd76026 100644 --- a/api/ContextProjects.tsx +++ b/api/ContextProjects.tsx @@ -23,6 +23,24 @@ interface ProjectsContextType { myNextActions: string, othersNextActions: string ) => Promise; + saveProjectName: ( + projectId: string, + projectName: string + ) => Promise; + saveProjectDates: (props: { + projectId: string; + dueDate?: Date; + doneOn?: Date; + onHoldTill?: Date; + }) => Promise; + updateProjectState: ( + projectId: string, + done: boolean + ) => Promise; + addAccountToProject: ( + projectId: string, + accountId: string + ) => Promise; } export type Project = { @@ -50,6 +68,7 @@ const selectionSet = [ "othersNextActions", "context", "accounts.accountId", + "accounts.createdAt", "activities.activity.id", "activities.activity.finishedOn", "activities.activity.createdAt", @@ -79,7 +98,13 @@ export const mapProject: (project: ProjectData) => Project = ({ myNextActions: myNextActions || "", othersNextActions: othersNextActions || "", context, - accountIds: accounts.map(({ accountId }) => accountId), + accountIds: accounts + .map(({ accountId, createdAt }) => ({ + accountId, + createdAt: new Date(createdAt), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(({ accountId }) => accountId), activityIds: activities .filter(({ activity }) => !!activity) .map(({ activity: { id, createdAt, finishedOn } }) => ({ @@ -173,27 +198,102 @@ export const ProjectsContextProvider: FC = ({ return data.activityId; }; - const saveNextActions = async ( + type UpdateProjectProps = { + id: string; + project?: string; + dueOn?: Date; + doneOn?: Date; + onHoldTill?: Date; + myNextActions?: string; + othersNextActions?: string; + done?: boolean; + }; + + const updateProject = async ({ + id, + project, + done, + doneOn, + dueOn, + onHoldTill, + myNextActions, + othersNextActions, + }: UpdateProjectProps) => { + const updated: Project[] = + projects?.map((p) => + p.id !== id + ? p + : { + ...p, + project: !project ? p.project : project, + done: done === undefined ? p.done : done, + doneOn: !doneOn ? p.doneOn : doneOn, + dueOn: !dueOn ? p.dueOn : dueOn, + onHoldTill: !onHoldTill ? p.onHoldTill : onHoldTill, + myNextActions: !myNextActions ? p.myNextActions : myNextActions, + othersNextActions: !othersNextActions + ? p.othersNextActions + : othersNextActions, + } + ) || []; + mutate(updated, false); + const newProject = { + id, + project, + done, + myNextActions, + othersNextActions, + dueOn: dueOn ? dueOn.toISOString().split("T")[0] : undefined, + doneOn: doneOn ? doneOn.toISOString().split("T")[0] : undefined, + onHoldTill: onHoldTill + ? onHoldTill.toISOString().split("T")[0] + : undefined, + }; + const { data, errors } = await client.models.Projects.update(newProject); + if (errors) handleApiErrors(errors, "Error updating project"); + mutate(updated); + return data?.id; + }; + + const saveNextActions = ( projectId: string, myNextActions: string, othersNextActions: string - ) => { + ) => updateProject({ id: projectId, myNextActions, othersNextActions }); + + const saveProjectName = (projectId: string, projectName: string) => + updateProject({ id: projectId, project: projectName }); + + const saveProjectDates = ({ + projectId, + dueOn, + doneOn, + onHoldTill, + }: { + projectId: string; + dueOn?: Date; + doneOn?: Date; + onHoldTill?: Date; + }) => updateProject({ id: projectId, dueOn, onHoldTill, doneOn }); + + const updateProjectState = (projectId: string, done: boolean) => + updateProject({ id: projectId, done, doneOn: new Date() }); + + const addAccountToProject = async (projectId: string, accountId: string) => { const updated: Project[] = - projects?.map((project) => - project.id !== projectId - ? project - : { ...project, myNextActions, othersNextActions } + projects?.map((p) => + p.id !== projectId + ? p + : { ...p, accountIds: [...p.accountIds, accountId] } ) || []; - mutate(updated, false); - const { data, errors } = await client.models.Projects.update({ - id: projectId, - myNextActions, - othersNextActions, + const { data, errors } = await client.models.AccountProjects.create({ + projectsId: projectId, + accountId, }); - if (errors) handleApiErrors(errors, "Error saving project's next actions"); + if (errors) handleApiErrors(errors, "Error adding account to project"); mutate(updated); - return data.id; + return data?.id; }; return ( @@ -206,6 +306,10 @@ export const ProjectsContextProvider: FC = ({ getProjectById, createProjectActivity, saveNextActions, + saveProjectName, + saveProjectDates, + updateProjectState, + addAccountToProject, }} > {children} diff --git a/api/useAccount.ts b/api/useAccount.ts deleted file mode 100644 index dbb10bc91..000000000 --- a/api/useAccount.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type Schema } from "@/amplify/data/resource"; -import { generateClient } from "aws-amplify/data"; -import useSWR from "swr"; - -const client = generateClient(); - -type Account = { - id: string; - name: string; -}; - -const mapAccount: (account: Schema["Account"]) => Account = ({ id, name }) => ({ - id, - name, -}); - -const fetchAccount = (accountId?: string) => async () => { - if (!accountId) return; - const { data, errors } = await client.models.Account.get({ id: accountId }); - if (errors) throw errors; - return mapAccount(data); -}; - -const useAccount = (accountId?: string) => { - const { - data: account, - error: errorAccount, - isLoading: loadingAccount, - } = useSWR(`/api/account/${accountId}`, fetchAccount(accountId)); - - return { account, errorAccount, loadingAccount }; -}; - -export default useAccount; diff --git a/api/useMeeting.ts b/api/useMeeting.ts index 65b4371d6..5ec92c44d 100644 --- a/api/useMeeting.ts +++ b/api/useMeeting.ts @@ -6,7 +6,6 @@ import { handleApiErrors } from "./globals"; const client = generateClient(); type MeetingUpdateProps = { - meetingId: string; meetingOn: Date; title: string; }; @@ -29,21 +28,12 @@ const useMeeting = (meetingId?: string) => { mutate: mutateMeeting, } = useSWR(`/api/meetings/${meetingId}`, fetchMeeting(meetingId)); - const updateMeeting = async ({ - meetingId, - meetingOn, - title, - }: MeetingUpdateProps) => { - const updated: Meeting = { - id: meetingId, - topic: title, - meetingOn, - participantIds: [], - activityIds: [], - }; + const updateMeeting = async ({ meetingOn, title }: MeetingUpdateProps) => { + if (!meeting) return; + const updated: Meeting = { ...meeting, topic: title, meetingOn }; mutateMeeting(updated, false); const { data, errors } = await client.models.Meeting.update({ - id: meetingId, + id: meeting.id, topic: title, meetingOn: meetingOn.toISOString(), }); diff --git a/components/dayplan/dayplan-form.tsx b/components/dayplan/dayplan-form.tsx index 32f30d6f1..95c247417 100644 --- a/components/dayplan/dayplan-form.tsx +++ b/components/dayplan/dayplan-form.tsx @@ -33,7 +33,7 @@ const DayPlanForm: FC = ({ onSubmit }) => { /> - + Confirm ); diff --git a/components/ui-elements/DateSelector.module.css b/components/ui-elements/DateSelector.module.css index 5f6fd375f..7d649d998 100644 --- a/components/ui-elements/DateSelector.module.css +++ b/components/ui-elements/DateSelector.module.css @@ -1,17 +1,4 @@ -.dateChanger { - cursor: pointer; - font-size: var(--font-size-small); - padding-top: 0.5rem; - padding-bottom: 0.5rem; - width: 5rem; - border-radius: 1rem; - border: none; - margin-right: 0.4rem; - background-color: var(--color-btn); -} -.date { - border: 1px solid var(--color-btn); - margin-right: 0.4rem; - padding: 0.6rem; - border-radius: 1rem; +.picker { + border: 1px solid #ccc; + border-radius: 0.4rem; } diff --git a/components/ui-elements/account-selector.tsx b/components/ui-elements/account-selector.tsx new file mode 100644 index 000000000..e4ababa5b --- /dev/null +++ b/components/ui-elements/account-selector.tsx @@ -0,0 +1,91 @@ +import { FC, ReactNode, useEffect, useState } from "react"; +import Select from "react-select"; +import CreatableSelect from "react-select/creatable"; +import AccountName from "./tokens/account-name"; +import { useAccountsContext } from "@/api/ContextAccounts"; + +type AccountSelectorProps = { + allowCreateAccounts?: boolean; + clearAfterSelection?: boolean; + onChange: (accountId: string | null) => void; +}; + +type AccountListOption = { + value: string; + label: ReactNode; +}; + +const AccountSelector: FC = ({ + allowCreateAccounts, + onChange, + clearAfterSelection, +}) => { + const { accounts, createAccount, loadingAccounts } = useAccountsContext(); + const [mappedOptions, setMappedOptions] = useState< + AccountListOption[] | undefined + >(); + const [selectedOption, setSelectedOption] = useState(null); + + useEffect(() => { + setMappedOptions( + accounts?.map((account) => ({ + value: account.id, + label: , + })) + ); + }, [accounts]); + + const selectAccount = async (selectedOption: any) => { + if (!(allowCreateAccounts && selectedOption.__isNew__)) { + onChange(selectedOption.value); + if (clearAfterSelection) setSelectedOption(null); + return; + } + const account = await createAccount(selectedOption.label); + if (account) onChange(account.id); + if (clearAfterSelection) setSelectedOption(null); + }; + + const filterAccounts = (accountId: string, input: string) => + !!accounts + ?.find(({ id }) => id === accountId) + ?.name.toLowerCase() + .includes(input.toLowerCase()); + + return ( +
+ {!allowCreateAccounts ? ( +