Skip to content

Commit

Permalink
feat(salaryAndBenefits): editable yearly salaries field with .csv upload
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiazom committed Aug 28, 2024
1 parent efdb4e9 commit 948e1cb
Show file tree
Hide file tree
Showing 7 changed files with 436 additions and 0 deletions.
146 changes: 146 additions & 0 deletions studio/components/SalaryCsvInput/SalariesInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { set, StringInputProps } from "sanity";
import { Box, Grid, Inline, Stack, Text, useTheme, useToast } from "@sanity/ui";
import { ChangeEvent } from "react";
import styles from "./salariesInput.module.css";
import { UploadIcon } from "@sanity/icons";
import {
salariesAsStoredString,
salariesFromCsvString,
salariesFromStoredString,
} from "./salariesParseUtils";
import { SalariesParseErrorsToastDescription } from "./SalariesParseErrorsToastDescription";
import SalaryNumberInput from "./SalaryNumberInput";

const UPLOAD_CSV_INPUT_ID = "upload-csv-input";

export const SalariesInput = (props: StringInputProps) => {
const toast = useToast();
const theme = useTheme();
const prefersDark = theme.sanity.v2?.color._dark ?? false;

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

async function handleFileRead(e: ProgressEvent<FileReader>): Promise<void> {
const fileData = e.target?.result;
if (fileData === null || typeof fileData !== "string") {
toast.push({
title: "Invalid data",
description: "Verify the file content and try again.",
status: "error",
});
return;
}
const salariesParseResult = salariesFromCsvString(fileData);
if (!salariesParseResult.ok) {
toast.push({
title: "Invalid salaries data",
description: (
<SalariesParseErrorsToastDescription
errors={salariesParseResult.error}
/>
),
status: "error",
});
return;
}
props.onChange(set(salariesAsStoredString(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") {
const reader = new FileReader();
reader.onload = handleFileRead;
reader.readAsText(file);
} else {
toast.push({
title: "Invalid salaries file type",
description: "Verify the file extension and try again.",
status: "error",
});
}
}
// reset to allow subsequent uploads of the same file
e.target.value = "";
}

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

return (
<Stack space={4} className={prefersDark ? styles.darkTheme : ""}>
<Inline space={2}>
<div>
{/*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}
accept=".csv"
/>
<Box className={styles.uploadButtonWrapper}>
<button className={styles.uploadButton} tabIndex={-1}>
<label
htmlFor={UPLOAD_CSV_INPUT_ID}
className={styles.uploadButtonContent}
>
<UploadIcon
color={"black"}
className={styles.uploadButtonIcon}
/>
<span className={styles.uploadButtonText}>Upload (.csv)</span>
</label>
</button>
</Box>
</div>
{salaries && Object.keys(salaries).length && (
<Text muted size={1}>
replaces all values below
</Text>
)}
</Inline>
{salaries && (
<Grid columns={[2]}>
{Object.entries(salaries)
.toSorted(([a], [b]) => Number(b) - Number(a))
.map(([year, salary], index) => (
<>
<div
key={year}
className={`${prefersDark ? styles.darkTheme : ""} ${styles.csvTableCell}`}
>
<label htmlFor={`salary-number-input-${year}`}>
<span className={styles.csvTableYearLabel}>{year}</span>
</label>
</div>
<div
key={`${year}-salary`}
className={`${prefersDark ? styles.darkTheme : ""} ${styles.csvTableCell}`}
>
<SalaryNumberInput
id={`salary-number-input-${year}`}
value={salary}
className={styles.csvTableSalaryInput}
onChange={(s) => handleYearSalaryChange(year, s)}
/>
</div>
</>
))}
</Grid>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
SalariesParseError,
SalariesParseErrorType,
} from "./salariesParseUtils";

function descriptionOfCsvParseError(error: SalariesParseError): string {
switch (error.error) {
case SalariesParseErrorType.INVALID_FORMAT:
return "File has invalid format";
case SalariesParseErrorType.NO_DATA:
return "File is empty";
case SalariesParseErrorType.INVALID_SHAPE:
return `Row ${error.rowNumber} has does not match format '{year},{salary}'`;
case SalariesParseErrorType.INVALID_DATA:
return `Row ${error.rowNumber} contains invalid data`;
}
}

export function SalariesParseErrorsToastDescription({
errors,
maxLines = 3,
}: {
errors: SalariesParseError[];
maxLines?: number;
}) {
return (
<div>
{errors.slice(0, maxLines).map((e, i) => (
<p key={i}>{descriptionOfCsvParseError(e)}</p>
))}
{errors.length > maxLines && (
<p>+ {errors.length - maxLines} more errors</p>
)}
</div>
);
}
35 changes: 35 additions & 0 deletions studio/components/SalaryCsvInput/SalaryNumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { HTMLProps, useState, ChangeEvent } from "react";
import { VALID_SALARY_REGEX } from "./salariesParseUtils";

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());

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}
/>
);
}
90 changes: 90 additions & 0 deletions studio/components/SalaryCsvInput/salariesInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
.uploadInput {
/* visually hidden input to still receive focus and support screen readers */
/* 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 */
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}

.uploadInput:is(:focus, :focus-within) + * label {
outline: 2px solid var(--focus-color);
}

.uploadButtonWrapper {
display: flex;
}

.uploadButtonContent {
display: inline-flex !important;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.6rem 0.45rem 0.5rem !important;
background-color: #252837; /* TODO: find Sanity Studio color variable */
border-radius: 0.1875rem;

&:hover {
background-color: #1b1d27; /* TODO: find Sanity Studio color variable */
}
}

.darkTheme .uploadButtonContent {
background-color: lightgray;
}

.uploadButton {
background: none;
border: none;
padding: 0;
font: inherit;
}

.uploadButtonIcon {
font-size: 1.35rem;
color: white;
}

.darkTheme .uploadButtonIcon {
color: black;
}

.uploadButtonText {
font-size: 0.8125rem;
color: white;
}

.darkTheme .uploadButtonText {
color: black;
}

.csvTableCell {
display: flex;
align-items: center;
font-size: 0.95rem;
padding: 0.25rem;
}

.csvTableCell:nth-child(4n + 1),
.csvTableCell:nth-child(4n + 2) {
background-color: #f5f5f5;
}

.darkTheme .csvTableCell:nth-child(4n + 1),
.darkTheme .csvTableCell:nth-child(4n + 2) {
background-color: #212121;
}

.csvTableSalaryInput {
border-radius: 4px;
border: 1px dotted #444;
padding: 0.5rem;
font-size: 0.95rem;
text-align: end;
width: 100%;
background-color: transparent;
}

.csvTableYearLabel {
padding: 0.5rem;
}
83 changes: 83 additions & 0 deletions studio/components/SalaryCsvInput/salariesParseUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Result, ResultError, ResultOk } from "../../utils/result";

export interface Salaries {
[year: string]: number;
}

const NON_EMPTY_DIGITS_ONLY_REGEX = new RegExp(/^\d+$/);
export const VALID_SALARY_REGEX = NON_EMPTY_DIGITS_ONLY_REGEX;

export enum SalariesParseErrorType {
INVALID_SHAPE,
INVALID_DATA,
NO_DATA,
INVALID_FORMAT,
}

export type SalariesParseError =
| {
error:
| SalariesParseErrorType.INVALID_SHAPE
| SalariesParseErrorType.INVALID_DATA;
rowNumber: number;
}
| {
error:
| SalariesParseErrorType.NO_DATA
| SalariesParseErrorType.INVALID_FORMAT;
};

/**
Naive CSV parser for files with row format "{year},{salary}"
Does not handle cases like escaped special characters
(salary number must therefore not contain commas)
*/
export function salariesFromCsvString(
csvString: string,
): Result<Salaries, SalariesParseError[]> {
const salaries: Salaries = {};
const errors: SalariesParseError[] = [];
const cleanCsvString = csvString.trim();
if (cleanCsvString.length === 0) {
return ResultError([{ error: SalariesParseErrorType.NO_DATA }]);
}
const rows = cleanCsvString.split("\n");
for (let i = 0; i < rows.length; i++) {
const cleanRow = rows[i]
.replace(/\s/g, "") // remove all whitespace
.replace(/,$/g, ""); // remove single trailing comma
const values = cleanRow.split(",");
if (values.length != 2) {
errors.push({
rowNumber: i,
error: SalariesParseErrorType.INVALID_SHAPE,
});
continue;
}
const [year, salary] = values;
if (
!(
NON_EMPTY_DIGITS_ONLY_REGEX.test(year) &&
VALID_SALARY_REGEX.test(salary)
)
) {
errors.push({ rowNumber: i, error: SalariesParseErrorType.INVALID_DATA });
continue;
}
salaries[Number(year)] = Number(salary);
}
if (errors.length > 0) {
return ResultError(errors);
}
return Object.keys(salaries).length > 0
? ResultOk(salaries)
: ResultError([{ error: SalariesParseErrorType.NO_DATA }]);
}

export function salariesAsStoredString(salaries: Salaries): string {
return JSON.stringify(salaries);
}

export function salariesFromStoredString(salaries: string): Salaries {
return JSON.parse(salaries);
}
Loading

0 comments on commit 948e1cb

Please sign in to comment.