Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add copy/paste functionality for workday entries #131

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
20 changes: 20 additions & 0 deletions web/src/components/workday-accordion/PasteEntryButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import ContentPasteGoIcon from "@mui/icons-material/ContentPasteGo";
import { Box, IconButtonProps } from "@mui/material";
import { useTranslation } from "react-i18next";
import LabelledIconButton from "../LabelledIconButton";

type PasteEntryButtonProps = IconButtonProps;

const PasteEntryButton = ({ ...props }: PasteEntryButtonProps) => {
const { t } = useTranslation();

return (
<Box onClick={(e) => e.stopPropagation()}>
<LabelledIconButton size="medium" label={t("controls.pasteEntry")} {...props}>
<ContentPasteGoIcon fontSize="inherit" />
</LabelledIconButton>
</Box>
);
};

export default PasteEntryButton;
48 changes: 47 additions & 1 deletion web/src/components/workday-accordion/WorkdaySummary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { AccordionSummary, Box, Chip, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { roundToFullMinutes, totalDurationOfEntries } from "../../common/duration";
import useDayjs from "../../common/useDayjs";
import {
Expand All @@ -10,14 +12,22 @@ import {
isVacation,
isWeekend,
} from "../../common/workdayUtils";
import { Workday } from "../../graphql/generated/graphql";
import {
AddWorkdayEntryDocument,
Entry,
FindWorkdaysDocument,
Workday,
} from "../../graphql/generated/graphql";
import EntryDialogButton from "../entry-dialog/EntryDialogButton";
import FlexLeaveChip from "./info-chips/FlexLeaveChip";
import HolidayChip from "./info-chips/HolidayChip";
import NoEntriesChip from "./info-chips/NoEntriesChip";
import SickLeaveChip from "./info-chips/SickLeaveChip";
import VacationChip from "./info-chips/VacationChip";
import WeekendChip from "./info-chips/WeekendChip";
import { useEntryContext } from "../workday-browser/entry-context/useEntryContext";
import { useNotification } from "../global-notification/useNotification";
import PasteEntryButton from "./PasteEntryButton";

type WorkdayAccordionProps = {
workday: Workday;
Expand All @@ -39,6 +49,34 @@ const WorkdaySummary = ({ workday }: WorkdayAccordionProps) => {
const totalHoursFormatted = roundToFullMinutes(totalDuration).format("H:mm");

const empty = workday.entries.length === 0;
const { t } = useTranslation();

const { selectedEntries, hasEntries, clearEntries } = useEntryContext();
const { showSuccessNotification } = useNotification();
const [addWorkdayEntryMutation] = useMutation(AddWorkdayEntryDocument, {
refetchQueries: [FindWorkdaysDocument],
onCompleted: async () => {
showSuccessNotification(t("notifications.addEntry.success"));
},
});
const handlePasteEntries = (entries: Entry[]) => {
entries.forEach((entry) => {
addWorkdayEntryMutation({
variables: {
entry: {
date: date.format("YYYY-MM-DD"),
duration: entry.duration,
description: entry.description,
product: entry.product,
activity: entry.activity,
issue: entry.issue,
client: entry.client,
},
},
});
});
clearEntries();
};

const InfoChip = () => {
if (vacation) {
Expand Down Expand Up @@ -88,6 +126,14 @@ const WorkdaySummary = ({ workday }: WorkdayAccordionProps) => {
</Box>
{!mobile && <InfoChip />}
<Box sx={{ display: "flex", alignItems: "center" }}>
{hasEntries ? (
<PasteEntryButton
onClick={(e) => {
e.stopPropagation();
handlePasteEntries(selectedEntries);
}}
/>
) : null}
{!disabled && (
<>
<EntryDialogButton date={date} size="medium" />
Expand Down
43 changes: 43 additions & 0 deletions web/src/components/workday-accordion/entry-row/CopyEntryButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { ToggleButton, Tooltip } from "@mui/material";
import { useTranslation } from "react-i18next";
import { Entry } from "../../../graphql/generated/graphql";
import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext";

type CopyEntryButtonProps = {
entry: Entry;
};

const CopyEntryButton = ({ entry }: CopyEntryButtonProps) => {
const { t } = useTranslation();
const { hasEntry, addSelectedEntry, removeSelectedEntry } = useEntryContext();

return (
<>
<Tooltip title={t("controls.copyEntry")} arrow placement="bottom">
<ToggleButton
value={t("controls.copyEntry")}
aria-label={t("controls.copyEntry")}
size="medium"
onClick={(e) => {
e.stopPropagation();
!hasEntry(entry) ? addSelectedEntry(entry) : removeSelectedEntry(entry);
}}
sx={(theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.primary.main
: theme.palette.secondary.dark,
border: "none",
borderRadius: "50%",
})}
selected={hasEntry(entry)}
>
<ContentCopyIcon fontSize="small" />
</ToggleButton>
</Tooltip>
</>
);
};

export default CopyEntryButton;
12 changes: 12 additions & 0 deletions web/src/components/workday-accordion/entry-row/DesktopEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { EntryRowProps } from "./EntryRow";
import AcceptedChip from "./status-chips/AcceptedChip";
import OpenChip from "./status-chips/OpenChip";
import PaidChip from "./status-chips/PaidChip";
import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext";
import CopyEntryButton from "./CopyEntryButton";

const DesktopEntryRow = ({ entry, date }: EntryRowProps) => {
const { darkMode } = useDarkMode();
Expand All @@ -19,6 +21,7 @@ const DesktopEntryRow = ({ entry, date }: EntryRowProps) => {
const paid = entry.acceptanceStatus === AcceptanceStatus.Paid;
const open = entry.acceptanceStatus === AcceptanceStatus.Open;
const roundedDuration = roundToFullMinutes(dayjs.duration(entry.duration, "hour"));
const { hasEntry } = useEntryContext();

return (
<ListItem
Expand All @@ -33,6 +36,12 @@ const DesktopEntryRow = ({ entry, date }: EntryRowProps) => {
display: "flex",
alignItems: "stretch",
justifyContent: "space-between",
backgroundColor: (theme) =>
hasEntry(entry)
? darkMode
? theme.palette.grey[700]
: theme.palette.primary.main
: undefined,
}}
>
<Box
Expand Down Expand Up @@ -85,6 +94,9 @@ const DesktopEntryRow = ({ entry, date }: EntryRowProps) => {
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box>
<CopyEntryButton entry={entry} />
</Box>
<Box>
<EditEntryButton date={date} entry={entry} />
</Box>
Expand Down
10 changes: 10 additions & 0 deletions web/src/components/workday-accordion/entry-row/MobileEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { EntryRowProps } from "./EntryRow";
import AcceptedChip from "./status-chips/AcceptedChip";
import OpenChip from "./status-chips/OpenChip";
import PaidChip from "./status-chips/PaidChip";
import CopyEntryButton from "./CopyEntryButton";
import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext";

const MobileEntryRow = ({ entry, date }: EntryRowProps) => {
const { darkMode } = useDarkMode();
Expand All @@ -18,6 +20,7 @@ const MobileEntryRow = ({ entry, date }: EntryRowProps) => {
const paid = entry.acceptanceStatus === AcceptanceStatus.Paid;
const open = entry.acceptanceStatus === AcceptanceStatus.Open;
const roundedDuration = roundToFullMinutes(dayjs.duration(entry.duration, "hour"));
const { hasEntry } = useEntryContext();

return (
<ListItem
Expand All @@ -32,6 +35,12 @@ const MobileEntryRow = ({ entry, date }: EntryRowProps) => {
display: "flex",
flexDirection: "column",
alignItems: "stretch",
backgroundColor: (theme) =>
hasEntry(entry)
? darkMode
? theme.palette.grey[700]
: theme.palette.primary.main
: undefined,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", minHeight: 40 }}>
Expand All @@ -58,6 +67,7 @@ const MobileEntryRow = ({ entry, date }: EntryRowProps) => {
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box>
<CopyEntryButton entry={entry} />
<EditEntryButton date={date} entry={entry} />
</Box>
</Box>
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/workday-browser/WorkdayBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Outlet } from "react-router-dom";
import ListControls from "./ListControls";
import WorkdayList from "./WorkdayList";
import { EntryContextProvider } from "./entry-context/EntryContextProvider";

const WorkdayBrowser = () => (
<>
<ListControls />
<WorkdayList />
<EntryContextProvider>
<ListControls />
<WorkdayList />
</EntryContextProvider>
<Outlet />
</>
);
Expand Down
13 changes: 13 additions & 0 deletions web/src/components/workday-browser/entry-context/EntryContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext } from "react";
import { Entry } from "../../../graphql/generated/graphql";

type EntryContextValue = {
selectedEntries: Entry[];
addSelectedEntry: (entry: Entry) => void;
removeSelectedEntry: (entry: Entry) => void;
clearEntries: () => void;
hasEntry: (entry: Entry) => boolean;
hasEntries: boolean;
};

export const EntryContext = createContext<EntryContextValue | undefined>(undefined);
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ReactNode, useState } from "react";
import { EntryContext } from "./EntryContext";
import { Entry } from "../../../graphql/generated/graphql";

type EntryContextProviderProps = {
children: ReactNode;
};

export const EntryContextProvider = ({ children }: EntryContextProviderProps) => {
const [selectedEntries, setSelected] = useState<Entry[]>([]);
const addSelectedEntry = (entry: Entry) => setSelected((prev) => [...prev, entry]);
const removeSelectedEntry = (entry: Entry) =>
setSelected((prev) => prev.filter((prevEntry) => prevEntry.key !== entry.key));
const hasEntry = (entry: Entry) =>
!!selectedEntries.find((prevEntry) => prevEntry.key === entry.key);
const clearEntries = () => setSelected([]);

return (
<EntryContext.Provider
value={{
selectedEntries,
addSelectedEntry,
removeSelectedEntry,
clearEntries,
hasEntry,
hasEntries: selectedEntries.length > 0,
}}
>
{children}
</EntryContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { EntryContext } from "./EntryContext";

export const useEntryContext = () => {
const entryContext = useContext(EntryContext);
if (entryContext === undefined) {
throw new Error("context must be inside a entryProvider");
}
return entryContext;
};
2 changes: 2 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const en = {
jiraDisconnect: "Disconnect from Jira",
showWeekend: "Show Weekend",
hideWeekend: "Hide Weekend",
copyEntry: "Copy Entry",
pasteEntry: "Paste Entry",
},
dimensionNames: {
product: "Product",
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const fi = {
jiraDisconnect: "Katkaise Jira-yhteys",
showWeekend: "Näytä Viikonloppu",
hideWeekend: "Piilota Viikonloppu",
copyEntry: "Kopioi työaikakirjaus",
pasteEntry: "Liitä työaikakirjaus",
},
dimensionNames: {
product: "Tuote",
Expand Down