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

v3 - calculator salary data input for Sanity Studio #535

Merged
merged 21 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fa9568a
feat(SalariesInput): editable yearly salaries field with .csv upload
mathiazom Aug 28, 2024
962a8fc
feat(compensations): add salaries input
mathiazom Sep 9, 2024
b00f993
feat(SalariesInput): add headers to salaries input table
mathiazom Sep 5, 2024
754dd25
refactor(SalariesInput): simplify table CSS class names
mathiazom Sep 5, 2024
e24b9ea
docs(result): type usage documentation
mathiazom Sep 5, 2024
20de1e1
feat(SalariesParseErrorsToastDescription): more informative error mes…
mathiazom Sep 5, 2024
50e929d
refactor(compensations): type failsafe for location duplicate check
mathiazom Sep 5, 2024
49fb1cb
refactor(salariesInput): break down SalariesInput into smaller compon…
mathiazom Sep 5, 2024
34d6d5f
feat(salariesByLocation): improve descriptions for salaries input
mathiazom Sep 5, 2024
9e8370e
feat(salariesByLocation): improve year description for salaries input
mathiazom Sep 5, 2024
c96bebc
feat(compensations): common preview formats
mathiazom Sep 9, 2024
9031378
feat(salariesByLocation): show latest year in preview subtitle
mathiazom Sep 9, 2024
698c2b1
fix(SalaryNumberInput): allow parent value to override internal raw v…
mathiazom Sep 9, 2024
cc54ebe
feat(SalariesFileUpload): remove button wrapper
mathiazom Sep 9, 2024
5536692
feat(SalariesFileUpload): show filename after upload
mathiazom Sep 9, 2024
a9a37da
feat(salariesByLocation): make yearlySalaries sortable
mathiazom Sep 9, 2024
16968ab
feat(SalariesFileUpload): clear input on click instead of on file read
mathiazom Sep 9, 2024
aed3a09
feat(SalariesInput): salaries table description
mathiazom Sep 9, 2024
06c19fa
feat(SalariesInput): add hasValue state with custom styling
mathiazom Sep 9, 2024
88fda1b
feat(SalariesInput): minor adjustments
mathiazom Sep 10, 2024
37b87d8
refactor(parseSalaries): for → forEach
mathiazom Sep 10, 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
71 changes: 71 additions & 0 deletions studio/components/salariesInput/SalariesInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { set, StringInputProps } from "sanity";
import { Inline, Stack, Text, useToast } from "@sanity/ui";
import {
Salaries,
salariesAsStoredString,
salariesFromStoredString,
SalariesParseError,
} from "./utils/parseSalaries";
import { SalariesParseErrorsToastDescription } from "./components/SalariesParseErrorsToastDescription";
import SalariesFileUpload from "./components/SalariesFileUpload";
import SalariesTableEditor from "./components/SalariesTableEditor";
import { useState } from "react";

export const SalariesInput = (props: StringInputProps) => {
const toast = useToast();

const [hasValue, setHasValue] = useState(props.value !== undefined);

const salaries =
props.value === undefined
? undefined
: salariesFromStoredString(props.value);

function handleYearSalaryChange(year: string, salary: number): void {
props.onChange(
set(
salariesAsStoredString({
...salaries,
[year]: salary,
}),
),
);
}

function handleSalariesChangedFromFile(salariesFromFile: Salaries) {
props.onChange(set(salariesAsStoredString(salariesFromFile)));
setHasValue(true);
}

function handleSalariesFileParseErrors(errors: SalariesParseError[]) {
toast.push({
title: "Invalid salaries data",
description: <SalariesParseErrorsToastDescription errors={errors} />,
status: "error",
duration: 10000,
});
}

return (
<Stack space={4}>
<Inline space={2}>
<SalariesFileUpload
hasValue={hasValue}
onSalariesChanged={handleSalariesChangedFromFile}
onParseErrors={handleSalariesFileParseErrors}
/>
</Inline>
{salaries && (
<Stack space={2}>
<Text size={1} muted>
Individual salary amounts can be edited in the table below:
</Text>
<SalariesTableEditor
salaries={salaries}
onYearSalaryChange={handleYearSalaryChange}
/>
</Stack>
)}
</Stack>
);
};
101 changes: 101 additions & 0 deletions studio/components/salariesInput/components/SalariesFileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import styles from "../salariesInput.module.css";
import { Box, Inline, Text } from "@sanity/ui";
import { UploadIcon } from "@sanity/icons";
import {
Salaries,
salariesFromCsvString,
SalariesParseError,
SalariesParseErrorType,
} from "../utils/parseSalaries";
import { ChangeEvent, useState, MouseEvent } from "react";

const UPLOAD_CSV_INPUT_ID = "upload-csv-input";

interface SalariesFileUploadProps {
hasValue?: boolean;
onSalariesChanged: (salaries: Salaries) => void;
onParseErrors: (errors: SalariesParseError[]) => void;
}

const SalariesFileUpload = ({
hasValue,
onSalariesChanged,
onParseErrors,
}: SalariesFileUploadProps) => {
const [filename, setFilename] = useState<string | null>(null);

async function handleFileRead(e: ProgressEvent<FileReader>): Promise<void> {
const fileData = e.target?.result;
if (fileData === null || typeof fileData !== "string") {
onParseErrors([{ error: SalariesParseErrorType.INVALID_FORMAT }]);
setFilename(null);
return;
}
const salariesParseResult = salariesFromCsvString(fileData);
if (!salariesParseResult.ok) {
onParseErrors(salariesParseResult.error);
setFilename(null);
return;
}
onSalariesChanged(salariesParseResult.value);
}

function handleFileChange(e: ChangeEvent<HTMLInputElement>): void {
if (e.target.files !== null && e.target.files.length > 0) {
const file = e.target.files[0];
if (file.type === "text/csv") {
setFilename(file.name);
const reader = new FileReader();
reader.onload = handleFileRead;
reader.readAsText(file);
} else {
onParseErrors([{ error: SalariesParseErrorType.INVALID_FORMAT }]);
}
}
}

function handleOnClick(e: MouseEvent<HTMLInputElement>) {
/*
resets input to allow subsequent uploads of the same file
has the downside that the file input will be cleared even if the user only cancels the file dialog without uploading a file
*/
e.currentTarget.value = "";
}

return (
<div>
{/*
Using label for hidden input as a custom file upload button, based on:
https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications#using_a_label_element_to_trigger_a_hidden_file_input_element
*/}
<input
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
id={UPLOAD_CSV_INPUT_ID}
className={styles.uploadInput}
type={"file"}
onChange={handleFileChange}
onClick={handleOnClick}
accept=".csv"
/>
<Inline space={2}>
<Box className={styles.uploadButtonWrapper}>
<label
htmlFor={UPLOAD_CSV_INPUT_ID}
className={`${styles.uploadButtonContent}${hasValue ? ` ${styles.hasValue}` : ""}`}
>
<UploadIcon className={styles.uploadButtonIcon} />
<span className={styles.uploadButtonText}>
{hasValue ? "Re-upload" : "Upload"} (.csv)
</span>
</label>
</Box>
{filename && (
<Text muted size={1}>
{filename}
</Text>
)}
</Inline>
</div>
);
};

export default SalariesFileUpload;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
SalariesParseError,
SalariesParseErrorType,
} from "../utils/parseSalaries";

function descriptionOfSalariesParseError(error: SalariesParseError): string {
switch (error.error) {
case SalariesParseErrorType.INVALID_FORMAT:
return "Invalid file type. Only CSV files (extension .csv) are allowed.";
case SalariesParseErrorType.NO_DATA:
return "File is empty. Verify the file content and try again.";
case SalariesParseErrorType.INVALID_SHAPE:
return `Row ${error.rowIndex + 1} does not match the format '{year},{salary}'`;
case SalariesParseErrorType.INVALID_DATA:
return `Row ${error.rowIndex + 1} contains invalid salary data. Verify that each line has the format '{year},{salary}'.`;
}
}

export function SalariesParseErrorsToastDescription({
errors,
maxLines = 3,
}: {
errors: SalariesParseError[];
maxLines?: number;
}) {
return (
<>
{errors.slice(0, maxLines).map((e, i) => (
<p key={i}>{descriptionOfSalariesParseError(e)}</p>
))}
{errors.length > maxLines && (
<p>and {errors.length - maxLines} more errors</p>
)}
</>
);
}
46 changes: 46 additions & 0 deletions studio/components/salariesInput/components/SalariesTableEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import styles from "../salariesInput.module.css";
import SalaryNumberInput from "./SalaryNumberInput";
import { Grid } from "@sanity/ui";
import { Salaries } from "../utils/parseSalaries";

interface SalariesTableEditorProps {
salaries: Salaries;
onYearSalaryChange: (year: string, salary: number) => void;
}

const SalariesTableEditor = ({
salaries,
onYearSalaryChange,
}: SalariesTableEditorProps) => {
return (
<Grid columns={[2]}>
<div className={styles.tableHeader}>
<p className={styles.tableHeaderLabel}>Examination Year</p>
</div>
<div className={`${styles.tableHeader} ${styles.tableSalaryHeader}`}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For proper UU this should be a table with rows and columns I believe. but not entirely certain as there's an input in each row.. 🤔 Could you research this just a little for me?

<p className={styles.tableHeaderLabel}>Amount</p>
</div>
{Object.entries(salaries)
.toSorted(([a], [b]) => Number(b) - Number(a))
.map(([year, salary], index) => (
<>
<div key={year} className={styles.tableCell}>
<label htmlFor={`salary-number-input-${year}`}>
<p className={styles.tableYearLabel}>{year}</p>
</label>
</div>
<div key={`${year}-salary`} className={styles.tableCell}>
<SalaryNumberInput
id={`salary-number-input-${year}`}
value={salary}
className={styles.tableSalaryInput}
onChange={(s) => onYearSalaryChange(year, s)}
/>
</div>
</>
))}
</Grid>
);
};

export default SalariesTableEditor;
39 changes: 39 additions & 0 deletions studio/components/salariesInput/components/SalaryNumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { HTMLProps, useState, ChangeEvent, useEffect } from "react";
import { VALID_SALARY_REGEX } from "../utils/parseSalaries";

type SalaryNumberInputProps = Omit<
HTMLProps<HTMLInputElement>,
"value" | "onChange"
> & {
value: number;
onChange: (value: number) => void;
};

export default function SalaryNumberInput({
value,
onChange,
...props
}: SalaryNumberInputProps) {
const [rawValue, setRawValue] = useState<string>(value.toString());

useEffect(() => {
setRawValue(value.toString());
}, [value]);

function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
const newRawValue = e.target.value;
setRawValue(newRawValue);
if (VALID_SALARY_REGEX.test(newRawValue)) {
onChange(Number(newRawValue));
}
}

return (
<input
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
type="number"
value={rawValue}
onChange={handleInputChange}
{...props}
/>
);
}
Loading