Skip to content

Commit

Permalink
v3 - calculator salary data input for Sanity Studio (#535)
Browse files Browse the repository at this point in the history
* feat(SalariesInput): editable yearly salaries field with .csv upload

* feat(compensations): add salaries input

* feat(SalariesInput): add headers to salaries input table

* refactor(SalariesInput): simplify table CSS class names

* docs(result): type usage documentation

* feat(SalariesParseErrorsToastDescription): more informative error messages

* refactor(compensations): type failsafe for location duplicate check

* refactor(salariesInput): break down SalariesInput into smaller components

* feat(salariesByLocation): improve descriptions for salaries input

* feat(salariesByLocation): improve year description for salaries input

* feat(compensations): common preview formats

* feat(salariesByLocation): show latest year in preview subtitle

* fix(SalaryNumberInput): allow parent value to override internal raw value

* feat(SalariesFileUpload): remove button wrapper

* feat(SalariesFileUpload): show filename after upload

* feat(salariesByLocation): make yearlySalaries sortable

since there is no way to specify a fixed ordering (only custom user order)

* feat(SalariesFileUpload): clear input on click instead of on file read

This allows us to still provide the filename to screen readers after file upload. The file will now be cleared when the user starts to upload a new file (note that the file will also be cleared if the user only cancels the file dialog without uploading a file).

* feat(SalariesInput): salaries table description

* feat(SalariesInput): add hasValue state with custom styling

To clarify that a file has already been uploaded, and a new upload will override these values

* feat(SalariesInput): minor adjustments

* refactor(parseSalaries): for → forEach
  • Loading branch information
mathiazom authored Sep 10, 2024
1 parent 6032714 commit bd1df00
Show file tree
Hide file tree
Showing 13 changed files with 665 additions and 48 deletions.
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
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}`}>
<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
type="number"
value={rawValue}
onChange={handleInputChange}
{...props}
/>
);
}
Loading

0 comments on commit bd1df00

Please sign in to comment.