diff --git a/studio/components/SalaryCsvInput/SalariesInput.tsx b/studio/components/SalaryCsvInput/SalariesInput.tsx new file mode 100644 index 000000000..859a5a0a6 --- /dev/null +++ b/studio/components/SalaryCsvInput/SalariesInput.tsx @@ -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): Promise { + 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: ( + + ), + status: "error", + }); + return; + } + props.onChange(set(salariesAsStoredString(salariesParseResult.value))); + } + + function handleFileChange(e: ChangeEvent): 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 ( + + +
+ {/*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*/} + + + + +
+ {salaries && Object.keys(salaries).length && ( + + replaces all values below + + )} +
+ {salaries && ( + + {Object.entries(salaries) + .toSorted(([a], [b]) => Number(b) - Number(a)) + .map(([year, salary], index) => ( + <> +
+ +
+
+ handleYearSalaryChange(year, s)} + /> +
+ + ))} +
+ )} +
+ ); +}; diff --git a/studio/components/SalaryCsvInput/SalariesParseErrorsToastDescription.tsx b/studio/components/SalaryCsvInput/SalariesParseErrorsToastDescription.tsx new file mode 100644 index 000000000..f665273df --- /dev/null +++ b/studio/components/SalaryCsvInput/SalariesParseErrorsToastDescription.tsx @@ -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 ( +
+ {errors.slice(0, maxLines).map((e, i) => ( +

{descriptionOfCsvParseError(e)}

+ ))} + {errors.length > maxLines && ( +

+ {errors.length - maxLines} more errors

+ )} +
+ ); +} diff --git a/studio/components/SalaryCsvInput/SalaryNumberInput.tsx b/studio/components/SalaryCsvInput/SalaryNumberInput.tsx new file mode 100644 index 000000000..ca40be69c --- /dev/null +++ b/studio/components/SalaryCsvInput/SalaryNumberInput.tsx @@ -0,0 +1,35 @@ +import { HTMLProps, useState, ChangeEvent } from "react"; +import { VALID_SALARY_REGEX } from "./salariesParseUtils"; + +type SalaryNumberInputProps = Omit< + HTMLProps, + "value" | "onChange" +> & { + value: number; + onChange: (value: number) => void; +}; + +export default function SalaryNumberInput({ + value, + onChange, + ...props +}: SalaryNumberInputProps) { + const [rawValue, setRawValue] = useState(value.toString()); + + function handleInputChange(e: ChangeEvent) { + const newRawValue = e.target.value; + setRawValue(newRawValue); + if (VALID_SALARY_REGEX.test(newRawValue)) { + onChange(Number(newRawValue)); + } + } + + return ( + + ); +} diff --git a/studio/components/SalaryCsvInput/salariesInput.module.css b/studio/components/SalaryCsvInput/salariesInput.module.css new file mode 100644 index 000000000..6b54fd107 --- /dev/null +++ b/studio/components/SalaryCsvInput/salariesInput.module.css @@ -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; +} diff --git a/studio/components/SalaryCsvInput/salariesParseUtils.tsx b/studio/components/SalaryCsvInput/salariesParseUtils.tsx new file mode 100644 index 000000000..9ead5dd72 --- /dev/null +++ b/studio/components/SalaryCsvInput/salariesParseUtils.tsx @@ -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 { + 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); +} diff --git a/studio/schemas/documents/salaryAndBenefits.ts b/studio/schemas/documents/salaryAndBenefits.ts index cf41bc2b2..e5eb2cdf3 100644 --- a/studio/schemas/documents/salaryAndBenefits.ts +++ b/studio/schemas/documents/salaryAndBenefits.ts @@ -2,6 +2,7 @@ import { defineField, defineType } from "sanity"; import { titleSlug } from "../schemaTypes/slug"; import seo from "../objects/seo"; import { title } from "../fields/text"; +import { SalariesInput } from "../../components/SalaryCsvInput/SalariesInput"; import { benefitId } from "./benefit"; import MultiLineDescription from "../../components/MultiLineDescription"; @@ -28,6 +29,42 @@ const salaryAndBenefits = defineType({ type: "boolean", initialValue: true, }), + defineField({ + name: "yearlySalaries", + title: "Yearly Salaries", + description: "Salary tiers for each year", + type: "array", + options: { + sortable: false, + }, + of: [ + { + type: "object", + fields: [ + defineField({ + name: "year", + title: "Year", + type: "number", + validation: (Rule) => Rule.required().min(2018), + }), + defineField({ + name: "salaries", + title: "Salaries", + type: "string", + components: { + input: SalariesInput, + }, + }), + ], + preview: { + select: { + title: "year", + salaries: "salaries", + }, + }, + }, + ], + }), defineField({ name: "benefits", title: "Benefits", diff --git a/studio/utils/result.ts b/studio/utils/result.ts new file mode 100644 index 000000000..5fb0821b9 --- /dev/null +++ b/studio/utils/result.ts @@ -0,0 +1,9 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export function ResultOk(value: T): Result { + return { ok: true, value }; +} + +export function ResultError(error: E): Result { + return { ok: false, error }; +}