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

Entry templates #134

Draft
wants to merge 26 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1b90b78
Create context for selected entry state
kurukimi Aug 8, 2024
94f33d0
Use entry context provider in workday list
kurukimi Aug 8, 2024
a5c0d4f
Add copy entry button
kurukimi Aug 8, 2024
1c8e147
Add paste entry button
kurukimi Aug 8, 2024
02c908d
Add translations for copy and paste buttons
kurukimi Aug 8, 2024
6e7454d
disable touch ripple on entry row
kurukimi Aug 8, 2024
77b9609
Remove clickaway listener
kurukimi Aug 8, 2024
99c8fc6
Move entry provider higher to keep state even in workdaylist render
kurukimi Aug 8, 2024
eb0188a
Use listitem role in listitembutton to not break e2e and keep desired…
kurukimi Aug 8, 2024
ac2e42a
Use togglebutton as copy button to set it visibly selected
kurukimi Aug 9, 2024
b263101
Use listitem for entryrow and change bgcolor when copy selected
kurukimi Aug 9, 2024
30928f0
Allow selecting multiple entries to copy at once
kurukimi Aug 9, 2024
5aa64b4
possibility to edit copied entries before sending
kurukimi Aug 9, 2024
aaa039b
Paste and edit multiple entries
kurukimi Aug 12, 2024
a0fd609
Add translations for paste and edit
kurukimi Aug 12, 2024
56be086
Pass template entries to entryform
kurukimi Aug 14, 2024
4e57231
Refactor entry row as seperate component from actions
kurukimi Aug 14, 2024
eed128d
Move entry form components to new folder
kurukimi Aug 14, 2024
4e2dd34
Add gql resolvers for adding and deleting templates
kurukimi Aug 15, 2024
69f3da8
Add form for adding templates
kurukimi Aug 15, 2024
4101f2a
List templates on the page
kurukimi Aug 15, 2024
5db09d1
Fix some types and imports
kurukimi Aug 16, 2024
a4ba49e
Add template delete button for desktop
kurukimi Aug 16, 2024
9d732a6
Add translations
kurukimi Aug 16, 2024
cb746f8
Adjust template accordion position
kurukimi Aug 16, 2024
21251be
Add delete template button mobile
kurukimi Aug 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions server/src/database/migrations/1723700076287-entry-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";

export class EntryTemplates1723700076287 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
"user_settings",
new TableColumn({
name: "entryTemplates",
type: "json",
isNullable: true,
}),
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("user_settings", "entryTemplates");
}
}
46 changes: 46 additions & 0 deletions server/src/user-settings/dto/entry-template.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Field, InputType, ObjectType } from "@nestjs/graphql";
import { IsDimensionValue } from "../../netvisor/dimension/is-dimension-value.decorator";
import { IsNumberString } from "class-validator";

@ObjectType({ isAbstract: true })
@InputType({ isAbstract: true })
class EntryTemplate {
@Field()
duration: number;

@Field()
description: string;

@IsDimensionValue()
@Field(() => String, { nullable: true })
product: string | null;

@IsDimensionValue()
@Field(() => String, { nullable: true })
activity: string | null;

@IsDimensionValue()
@Field(() => String, { nullable: true })
issue: string | null;

@IsDimensionValue()
@Field(() => String, { nullable: true })
client: string | null;
}

@InputType()
export class EntryTemplateInput extends EntryTemplate {}

@ObjectType()
export class EntryTemplateType extends EntryTemplate {
@IsNumberString()
@Field()
key: string;
}

@InputType()
export class RemoveEntryTemplateInput {
@Field()
@IsNumberString()
key: string;
}
5 changes: 5 additions & 0 deletions server/src/user-settings/user-settings.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Field, ObjectType } from "@nestjs/graphql";
import { Column, Entity, PrimaryColumn } from "typeorm";
import { EntryTemplateType } from "./dto/entry-template.dto";

@Entity({ name: "user_settings" })
@ObjectType()
Expand All @@ -15,4 +16,8 @@ export class UserSettings {
@Column({ nullable: true })
@Field({ nullable: true })
activityPreset: string;

@Column("simple-json", { nullable: true })
@Field(() => [EntryTemplateType], { nullable: true })
entryTemplates: EntryTemplateType[];
}
17 changes: 17 additions & 0 deletions server/src/user-settings/user-settings.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EmployeeNumber } from "../decorators/employee-number.decorator";
import { UpdateSettingsDto } from "./dto/update-settings.dto";
import { UserSettings } from "./user-settings.model";
import { UserSettingsService } from "./user-settings.service";
import { EntryTemplateInput, RemoveEntryTemplateInput } from "./dto/entry-template.dto";

@Resolver()
export class UserSettingsResolver {
Expand All @@ -20,4 +21,20 @@ export class UserSettingsResolver {
) {
return this.userSettingsService.update(employeeNumber, update);
}

@Mutation(() => UserSettings)
async addEntryTemplate(
@EmployeeNumber() employeeNumber: number,
@Args("template") entry: EntryTemplateInput,
) {
return this.userSettingsService.addEntryTemplate(employeeNumber, entry);
}

@Mutation(() => UserSettings)
async removeEntryTemplate(
@EmployeeNumber() employeeNumber: number,
@Args("templateKey") entryKey: RemoveEntryTemplateInput,
) {
return this.userSettingsService.removeEntryTemplate(employeeNumber, entryKey.key);
}
}
24 changes: 24 additions & 0 deletions server/src/user-settings/user-settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { UpdateSettingsDto } from "./dto/update-settings.dto";
import { UserSettings } from "./user-settings.model";
import { EntryTemplateInput } from "./dto/entry-template.dto";

@Injectable()
export class UserSettingsService {
Expand Down Expand Up @@ -32,6 +33,29 @@ export class UserSettingsService {
return this.findOneByEmployeeNumber(employeeNumber);
}

async addEntryTemplate(employeeNumber: number, entry: EntryTemplateInput): Promise<UserSettings> {
const settings = await this.findOneByEmployeeNumber(employeeNumber);
await this.userSettings.update(
{ employeeNumber },
{
entryTemplates: [
...(settings.entryTemplates || []),
{ key: (settings.entryTemplates?.length || 0).toString(), ...entry },
],
},
);
return this.findOneByEmployeeNumber(employeeNumber);
}

async removeEntryTemplate(employeeNumber: number, entryKey: string): Promise<UserSettings> {
const settings = await this.findOneByEmployeeNumber(employeeNumber);
await this.userSettings.update(
{ employeeNumber },
{ entryTemplates: settings.entryTemplates.filter((entry) => entry.key !== entryKey) },
);
return this.findOneByEmployeeNumber(employeeNumber);
}

/**
* Create user settings database entry for user.
*
Expand Down
11 changes: 6 additions & 5 deletions web/src/components/entry-dialog/DurationSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Box, Slider } from "@mui/material";
import { TimeField } from "@mui/x-date-pickers-pro";
import { Dayjs } from "dayjs";
import { ControllerRenderProps } from "react-hook-form";
import { ControllerRenderProps, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import useDayjs from "../../common/useDayjs";
import { EntryFormSchema } from "./useEntryForm";

type DurationSliderProps = {
field: ControllerRenderProps<EntryFormSchema, "duration">;
type DurationSliderProps<T extends FieldValues, E extends FieldPath<T>> = {
field: ControllerRenderProps<T, E>;
};

const DurationSlider = ({ field }: DurationSliderProps) => {
const DurationSlider = <T extends FieldValues, E extends FieldPath<T>>({
field,
}: DurationSliderProps<T, E>) => {
const { t } = useTranslation();
const dayjs = useDayjs();
const hoursDecimal = Number(field.value || 0);
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/entry-dialog/EntryDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import BigDialog from "../BigDialog";
import EntryForm from "./EntryForm";
import EntryForm from "../entry-form/EntryForm";

type EntryDialogProps = {
variant: "create" | "edit";
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/entry-dialog/ResponsiveDatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DatePicker, StaticDatePicker } from "@mui/x-date-pickers-pro";
import { ControllerRenderProps } from "react-hook-form";
import { useTranslation } from "react-i18next";
import useDayjs from "../../common/useDayjs";
import { EntryFormSchema } from "./useEntryForm";
import { EntryFormSchema } from "../entry-form/useEntryForm";

type ResponsiveDatePickerProps = {
field: ControllerRenderProps<EntryFormSchema, "date">;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import { useLocation, useNavigate } from "react-router-dom";
import useDayjs from "../../common/useDayjs";
import { AcceptanceStatus, Entry } from "../../graphql/generated/graphql";
import usePreferSetRemainingHours from "../user-preferences/usePreferSetRemainingHours";
import BigDeleteEntryButton from "./BigDeleteEntryButton";
import DimensionComboBox from "./DimensionComboBox";
import DurationSlider from "./DurationSlider";
import ResponsiveDatePicker from "./ResponsiveDatePicker";
import WorkdayHours from "./WorkdayHours";
import BigDeleteEntryButton from "../entry-dialog/BigDeleteEntryButton";
import DimensionComboBox from "../entry-dialog/DimensionComboBox";
import DurationSlider from "../entry-dialog/DurationSlider";
import ResponsiveDatePicker from "../entry-dialog/ResponsiveDatePicker";
import WorkdayHours from "../entry-dialog/WorkdayHours";
import useEntryForm, { EntryFormSchema } from "./useEntryForm";
import { useIsJiraAuthenticated } from "../../jira/jiraApi";
import JiraIssueComboBox from "../../jira/components/JiraIssueComboBox";
Expand All @@ -44,14 +44,21 @@ export type EntryFormProps = {
type LocationState = {
date?: string;
editEntry?: Entry;
template?: Entry;
templateEntries?: Entry[];
};

const EntryForm = () => {
const { state } = useLocation();
const dayjs = useDayjs();
// state is possibly null
const { date: originalDate, editEntry }: LocationState = state || {};
const { form, onSubmit, loading } = useEntryForm({ editEntry, date: dayjs(originalDate) });
const { date: originalDate, editEntry, templateEntries }: LocationState = state || {};

const { form, onSubmit, loading } = useEntryForm({
editEntry,
date: dayjs(originalDate),
template: templateEntries && templateEntries[0],
});
const navigate = useNavigate();

const {
Expand All @@ -62,10 +69,25 @@ const EntryForm = () => {

useEffect(() => {
if (isSubmitSuccessful) {
reset();
navigate("..");
const remainingEntries = templateEntries && templateEntries.slice(1);
if (remainingEntries && remainingEntries.length > 0) {
const nextEntry = remainingEntries[0];
reset({
date: dayjs(originalDate),
duration: nextEntry.duration.toString(),
description: nextEntry.description || "",
product: nextEntry.product || "",
activity: nextEntry.activity || "",
issue: nextEntry.issue || null,
client: nextEntry.client || "",
});
navigate(".", { state: { date: originalDate, templateEntries: remainingEntries } });
} else {
reset();
navigate("..");
}
}
}, [isSubmitSuccessful, navigate, reset]);
}, [dayjs, isSubmitSuccessful, navigate, originalDate, reset, templateEntries]);

const { t } = useTranslation();
const theme = useTheme();
Expand Down Expand Up @@ -197,7 +219,7 @@ const EntryForm = () => {
)}
</Grid>
<Grid item xs={12}>
{!editEntry && (
{!editEntry && !templateEntries && (
<FormGroup>
<FormControlLabel
control={
Expand All @@ -217,7 +239,7 @@ const EntryForm = () => {
<>
<Grid item xs={4} sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "start", gap: 2 }}>
{!editEntry && (
{!editEntry && !templateEntries && (
<FormGroup>
<FormControlLabel
control={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type EntryFormSchema = {
export type useEntryProps = {
editEntry?: Entry;
date?: Dayjs;
template?: Entry;
};

/**
Expand All @@ -34,7 +35,7 @@ export type useEntryProps = {
* Note that calling this hook will create a new RHF form instance, i.e., you must pass down
* the actual instance from parent components rather than call this hook again in child components.
*/
const useEntryForm = ({ editEntry, date }: useEntryProps) => {
const useEntryForm = ({ editEntry, date, template }: useEntryProps) => {
const { t } = useTranslation();
const { showSuccessNotification } = useNotification();
const dayjs = useDayjs();
Expand Down Expand Up @@ -68,12 +69,17 @@ const useEntryForm = ({ editEntry, date }: useEntryProps) => {

return {
date: date ? dayjs(date) : dayjs(),
duration: editEntry?.duration.toString() || "",
description: editEntry?.description || "",
product: editEntry?.product || settingsData?.getMySettings.productPreset || "",
activity: editEntry?.activity || settingsData?.getMySettings.activityPreset || "",
issue: editEntry?.issue || null,
client: editEntry?.client || "",
duration: editEntry?.duration.toString() || template?.duration.toString() || "",
description: editEntry?.description || template?.description || "",
product:
editEntry?.product || template?.product || settingsData?.getMySettings.productPreset || "",
activity:
editEntry?.activity ||
template?.activity ||
settingsData?.getMySettings.activityPreset ||
"",
issue: editEntry?.issue || template?.issue || null,
client: editEntry?.client || template?.client || "",
};
};

Expand All @@ -83,7 +89,7 @@ const useEntryForm = ({ editEntry, date }: useEntryProps) => {

const { loading: hoursLoading } = useFormSetRemainingHours({
form,
isEnabled: editEntry === undefined && !form.formState.isLoading,
isEnabled: editEntry === undefined && template === undefined && !form.formState.isLoading,
});

const addWorkday: SubmitHandler<EntryFormSchema> = async (formValues) => {
Expand Down
27 changes: 27 additions & 0 deletions web/src/components/template-accordion/AddTemplateButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import AddIcon from "@mui/icons-material/Add";
import { Box, IconButtonProps } from "@mui/material";
import { useTranslation } from "react-i18next";
import { generatePath, useLocation, useNavigate } from "react-router-dom";
import LabelledIconButton from "../LabelledIconButton";

type CreateTemplateButtonProps = IconButtonProps;

const CreateTemplateButton = ({ ...props }: CreateTemplateButtonProps) => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();

return (
<Box onClick={(e) => e.stopPropagation()}>
<LabelledIconButton
label={t("controls.addTemplate")}
onClick={() => navigate(generatePath(`${location.pathname}/create-template`))}
{...props}
>
<AddIcon fontSize="inherit" />
</LabelledIconButton>
</Box>
);
};

export default CreateTemplateButton;
Loading