-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(salaryAndBenefits): editable yearly salaries field with .csv upload
- Loading branch information
Showing
7 changed files
with
434 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
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 : ""}> | ||
<pre>{salaries && JSON.stringify(salaries[2024], null, 2)}</pre> | ||
<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}`} | ||
> | ||
<span className={styles.csvTableYearLabel}>{year}</span> | ||
</div> | ||
<div | ||
key={`${year}-salary`} | ||
className={`${prefersDark ? styles.darkTheme : ""} ${styles.csvTableCell}`} | ||
> | ||
<SalaryNumberInput | ||
value={salary} | ||
className={styles.csvTableSalaryInput} | ||
onChange={(s) => handleYearSalaryChange(year, s)} | ||
/> | ||
</div> | ||
</> | ||
))} | ||
</Grid> | ||
)} | ||
</Stack> | ||
); | ||
}; |
36 changes: 36 additions & 0 deletions
36
studio/components/SalaryCsvInput/SalariesParseErrorsToastDescription.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.