-
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.
v3 - calculator salary data input for Sanity Studio (#535)
* 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
Showing
13 changed files
with
665 additions
and
48 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,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
101
studio/components/salariesInput/components/SalariesFileUpload.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,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; |
36 changes: 36 additions & 0 deletions
36
studio/components/salariesInput/components/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 "../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
46
studio/components/salariesInput/components/SalariesTableEditor.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,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
39
studio/components/salariesInput/components/SalaryNumberInput.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,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} | ||
/> | ||
); | ||
} |
Oops, something went wrong.