From f5d19c444bcebba4414f5811259c28648536583d Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Mon, 9 Sep 2024 11:33:36 +0200 Subject: [PATCH 1/3] v3 - basic sitemap generation (#580) * feat: add NEXT_PUBLIC_URL env variable * feat: basic sitemap generation * feat(sitemap): generate robots.txt with sitemap reference * feat(robots): disallow crawling of studio and api paths * fix(sitemap): fetch Sanity data with token --- .env.development | 1 + .env.production | 1 + src/app/robots.ts | 11 +++++++++++ src/app/sitemap.ts | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+) create mode 100644 .env.development create mode 100644 .env.production create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..812358268 --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_URL=http://localhost:3000 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..01b130c6a --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL \ No newline at end of file diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 000000000..3265d2247 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,11 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + disallow: ["/studio", "/shared", "/api"], + }, + sitemap: new URL("sitemap.xml", process.env.NEXT_PUBLIC_URL).toString(), + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 000000000..73652f949 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,21 @@ +import type { MetadataRoute } from "next"; +import { client } from "../../studio/lib/client"; +import { Slug } from "../../studio/lib/payloads/global"; +import { token } from "../../studio/lib/token"; + +interface SitemapDocument { + slug: Slug; + _updatedAt: string; +} + +const clientWithToken = client.withConfig({ token }); + +export default async function sitemap(): Promise { + const documents = + await clientWithToken.fetch(`*[defined(slug)]`); + + return documents.map((s) => ({ + url: new URL(s.slug.current, process.env.NEXT_PUBLIC_URL).toString(), + lastModified: new Date(s._updatedAt), + })); +} From 60327148792dba72b9bde1d7e38dde9f8658b6a5 Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:24:57 +0200 Subject: [PATCH 2/3] v3 - OpenGraph fallback image (#574) * feat(opengraph): generated fallback image * feat(OpenGraphImage): remove all custom assets * docs(OpenGraphImage): document open graph image customization * docs(OpenGraphImage): add protocol reference * feat(seo): include default SEO image url in OpenGraph image --- README.md | 71 +++++++++++++++++++ src/app/api/openGraphImage/OpenGraphImage.tsx | 51 +++++++++++++ src/app/api/openGraphImage/route.tsx | 16 +++++ src/utils/seo.ts | 14 ++-- studio/lib/queries/companyInfo.ts | 6 ++ 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/app/api/openGraphImage/OpenGraphImage.tsx create mode 100644 src/app/api/openGraphImage/route.tsx diff --git a/README.md b/README.md index 24b723a40..9c78efe44 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,77 @@ export default MyCustomComponent; By using fetchWithToken, you ensure that all data fetching happens securely, with the server-side API route handling the sensitive token. +### OpenGraph image customization + +As part of providing the basic metadata for the [OpenGraph Protocol](https://ogp.me), a fallback image is generated if no other is specified. Fonts and background can be customized as shown below. + +#### Custom fonts + +The following font utils file can be defined: + +> fonts must be placed in `/public/fonts` + +```tsx +import { readFile } from "fs/promises"; +import { join } from "path"; + +type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; +type FontStyle = "normal" | "italic"; +interface FontOptions { + data: Buffer | ArrayBuffer; + name: string; + weight?: Weight; + style?: FontStyle; + lang?: string; +} + +function readFont(filename: string) { + return readFile(join(process.cwd(), "public/fonts", filename)); +} + +export async function getFonts(): Promise { + return [ + { + data: await readFont("graphik_regular.woff"), + name: "Graphik", + weight: 400, + style: "normal", + }, + { + data: await readFont("graphik_medium.woff"), + name: "Graphik", + weight: 600, + style: "normal", + }, + { + data: await readFont("recoleta.ttf"), + name: "Recoleta", + weight: 600, + style: "normal", + }, + ]; +} +``` + +followed by a modification of the image route response: + +```tsx +(), + { + width: 1200, + height: 630, + fonts: await getFonts(), // add this line + }; +``` + +#### Custom background + +Simply use the CSS background property on the root element with a base64-encoded data url + +```css +background: url("data:image/png;base64,..."); +``` + ### Troubleshooting - Sanity Preview: While the Sanity preview functionality is not fully optimized, it currently meets the essential requirements. diff --git a/src/app/api/openGraphImage/OpenGraphImage.tsx b/src/app/api/openGraphImage/OpenGraphImage.tsx new file mode 100644 index 000000000..3046439c7 --- /dev/null +++ b/src/app/api/openGraphImage/OpenGraphImage.tsx @@ -0,0 +1,51 @@ +interface OpenGraphImageProps { + title: string; + description?: string; +} + +const OpenGraphImage = ({ title, description }: OpenGraphImageProps) => { + return ( +
+
+ + {title} + + {description && ( + + {description.length > 160 + ? description.substring(0, 160) + "[...]" + : description} + + )} +
+
+ ); +}; + +export default OpenGraphImage; diff --git a/src/app/api/openGraphImage/route.tsx b/src/app/api/openGraphImage/route.tsx new file mode 100644 index 000000000..8a7dc6e89 --- /dev/null +++ b/src/app/api/openGraphImage/route.tsx @@ -0,0 +1,16 @@ +import { ImageResponse } from "next/og"; +import { NextRequest } from "next/server"; +import OpenGraphImage from "./OpenGraphImage"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const title = searchParams.get("title") ?? "Variant"; + const description = searchParams.get("description"); + return new ImageResponse( + , + { + width: 1200, + height: 630, + }, + ); +} diff --git a/src/utils/seo.ts b/src/utils/seo.ts index 6f3cca680..7a2fb2113 100644 --- a/src/utils/seo.ts +++ b/src/utils/seo.ts @@ -26,6 +26,7 @@ type CompanyInfo = { brandAssets: { favicon: string; }; + defaultSEO: SeoData; }; export async function fetchSeoData( @@ -80,10 +81,8 @@ export async function generateMetadataFromSeo( ): Promise { const companyInfo = await fetchCompanyInfo(); - const title = - seo?.title || companyInfo?.siteMetadata?.siteName || "Fallback Title"; - const description = seo?.description || ""; - const imageUrl = seo?.imageUrl || ""; + const title = seo?.title || companyInfo?.siteMetadata?.siteName || "Variant"; + const description = seo?.description; const keywords = seo?.keywords || ""; const favicon = companyInfo?.brandAssets?.favicon; @@ -93,6 +92,13 @@ export async function generateMetadataFromSeo( (icon): icon is NonNullable => icon !== null, ); + const fallbackImageUrl = `/api/openGraphImage?${new URLSearchParams({ + title: title, + ...(description ? { description: description } : {}), + })}`; + const imageUrl = + seo?.imageUrl || companyInfo?.defaultSEO?.imageUrl || fallbackImageUrl; + return { title: title, description: description, diff --git a/studio/lib/queries/companyInfo.ts b/studio/lib/queries/companyInfo.ts index 75f8d3b25..54452eab9 100644 --- a/studio/lib/queries/companyInfo.ts +++ b/studio/lib/queries/companyInfo.ts @@ -3,5 +3,11 @@ import { groq } from "next-sanity"; export const COMPANY_INFO_QUERY = groq`*[_type == "companyInfo"]{ brandAssets, siteMetadata, + defaultSEO { + "title": seoTitle, + "description": seoDescription, + "keywords": seoKeywords, + "imageUrl": seoImage.asset->url + }, legalPages, }[0]`; From bd1df008cb87a8b33c9d4556fe67e8183fe7602b Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:09:07 +0200 Subject: [PATCH 3/3] v3 - calculator salary data input for Sanity Studio (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../salariesInput/SalariesInput.tsx | 71 ++++++++++++ .../components/SalariesFileUpload.tsx | 101 ++++++++++++++++ .../SalariesParseErrorsToastDescription.tsx | 36 ++++++ .../components/SalariesTableEditor.tsx | 46 ++++++++ .../components/SalaryNumberInput.tsx | 39 +++++++ .../salariesInput/salariesInput.module.css | 108 ++++++++++++++++++ .../salariesInput/utils/parseSalaries.tsx | 86 ++++++++++++++ studio/lib/payloads/compensations.ts | 7 ++ studio/schemas/documents/compensations.ts | 3 +- .../compensations/benefitsByLocation.ts | 6 +- .../compensations/bonusesByLocation.ts | 56 +++------ .../compensations/salariesByLocation.ts | 100 ++++++++++++++++ studio/utils/result.ts | 54 +++++++++ 13 files changed, 665 insertions(+), 48 deletions(-) create mode 100644 studio/components/salariesInput/SalariesInput.tsx create mode 100644 studio/components/salariesInput/components/SalariesFileUpload.tsx create mode 100644 studio/components/salariesInput/components/SalariesParseErrorsToastDescription.tsx create mode 100644 studio/components/salariesInput/components/SalariesTableEditor.tsx create mode 100644 studio/components/salariesInput/components/SalaryNumberInput.tsx create mode 100644 studio/components/salariesInput/salariesInput.module.css create mode 100644 studio/components/salariesInput/utils/parseSalaries.tsx create mode 100644 studio/schemas/objects/compensations/salariesByLocation.ts create mode 100644 studio/utils/result.ts diff --git a/studio/components/salariesInput/SalariesInput.tsx b/studio/components/salariesInput/SalariesInput.tsx new file mode 100644 index 000000000..853fb2298 --- /dev/null +++ b/studio/components/salariesInput/SalariesInput.tsx @@ -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: , + status: "error", + duration: 10000, + }); + } + + return ( + + + + + {salaries && ( + + + Individual salary amounts can be edited in the table below: + + + + )} + + ); +}; diff --git a/studio/components/salariesInput/components/SalariesFileUpload.tsx b/studio/components/salariesInput/components/SalariesFileUpload.tsx new file mode 100644 index 000000000..bf770acc4 --- /dev/null +++ b/studio/components/salariesInput/components/SalariesFileUpload.tsx @@ -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(null); + + async function handleFileRead(e: ProgressEvent): Promise { + 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): 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) { + /* + 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 ( +
+ {/* + 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 + */} + + + + + + {filename && ( + + {filename} + + )} + +
+ ); +}; + +export default SalariesFileUpload; diff --git a/studio/components/salariesInput/components/SalariesParseErrorsToastDescription.tsx b/studio/components/salariesInput/components/SalariesParseErrorsToastDescription.tsx new file mode 100644 index 000000000..334715851 --- /dev/null +++ b/studio/components/salariesInput/components/SalariesParseErrorsToastDescription.tsx @@ -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) => ( +

{descriptionOfSalariesParseError(e)}

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

and {errors.length - maxLines} more errors

+ )} + + ); +} diff --git a/studio/components/salariesInput/components/SalariesTableEditor.tsx b/studio/components/salariesInput/components/SalariesTableEditor.tsx new file mode 100644 index 000000000..6bdced8e9 --- /dev/null +++ b/studio/components/salariesInput/components/SalariesTableEditor.tsx @@ -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 ( + +
+

Examination Year

+
+
+

Amount

+
+ {Object.entries(salaries) + .toSorted(([a], [b]) => Number(b) - Number(a)) + .map(([year, salary], index) => ( + <> +
+ +
+
+ onYearSalaryChange(year, s)} + /> +
+ + ))} +
+ ); +}; + +export default SalariesTableEditor; diff --git a/studio/components/salariesInput/components/SalaryNumberInput.tsx b/studio/components/salariesInput/components/SalaryNumberInput.tsx new file mode 100644 index 000000000..2a781090f --- /dev/null +++ b/studio/components/salariesInput/components/SalaryNumberInput.tsx @@ -0,0 +1,39 @@ +import { HTMLProps, useState, ChangeEvent, useEffect } from "react"; +import { VALID_SALARY_REGEX } from "../utils/parseSalaries"; + +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()); + + useEffect(() => { + setRawValue(value.toString()); + }, [value]); + + 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/salariesInput/salariesInput.module.css b/studio/components/salariesInput/salariesInput.module.css new file mode 100644 index 000000000..8093daa6c --- /dev/null +++ b/studio/components/salariesInput/salariesInput.module.css @@ -0,0 +1,108 @@ +.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; + color: white; + + &:hover { + background-color: #1b1d27; /* TODO: find Sanity Studio color variable */ + } + + &.hasValue { + background-color: rgb(246, 246, 248); + color: black; + border: 1px solid rgb(230, 230, 234); + } +} + +[data-scheme="dark"] .uploadButtonContent { + background-color: lightgray; + color: black; + + &.hasValue { + background-color: rgb(25, 26, 36); + color: rgb(228, 229, 233); + border: 1px solid rgb(228, 229, 233); + } +} + +.uploadButton { + background: none; + border: none; + padding: 0; + font: inherit; +} + +.uploadButtonIcon { + font-size: 1.35rem; +} + +.uploadButtonText { + font-size: 0.8125rem; +} + +.tableHeader { + font-weight: 600; + font-size: 0.8125rem; +} + +.tableSalaryHeader { + justify-content: end; +} + +.tableCell, +.tableHeader { + display: flex; + align-items: center; + padding: 0.25rem; +} + +.tableCell { + font-size: 0.95rem; +} + +.tableCell:nth-child(4n), +.tableCell:nth-child(4n + 3) { + background-color: #f5f5f5; +} + +[data-scheme="dark"] .tableCell:nth-child(4n), +[data-scheme="dark"] .tableCell:nth-child(4n + 3) { + background-color: #212121; +} + +.tableSalaryInput { + border-radius: 4px; + border: 1px dotted #444; + padding: 0.5rem; + font-size: 0.95rem; + text-align: end; + width: 100%; + background-color: transparent; +} + +.tableHeaderLabel, +.tableYearLabel { + padding: 0.5rem; +} diff --git a/studio/components/salariesInput/utils/parseSalaries.tsx b/studio/components/salariesInput/utils/parseSalaries.tsx new file mode 100644 index 000000000..f65817bb9 --- /dev/null +++ b/studio/components/salariesInput/utils/parseSalaries.tsx @@ -0,0 +1,86 @@ +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; + rowIndex: 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"); + rows.forEach((row, index) => { + const cleanRow = row + .replace(/\s/g, "") // remove all whitespace + .replace(/,$/g, ""); // remove single trailing comma + const values = cleanRow.split(","); + if (values.length != 2) { + errors.push({ + rowIndex: index, + error: SalariesParseErrorType.INVALID_SHAPE, + }); + return; + } + const [year, salary] = values; + if ( + !( + NON_EMPTY_DIGITS_ONLY_REGEX.test(year) && + VALID_SALARY_REGEX.test(salary) + ) + ) { + errors.push({ + rowIndex: index, + error: SalariesParseErrorType.INVALID_DATA, + }); + return; + } + 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/lib/payloads/compensations.ts b/studio/lib/payloads/compensations.ts index 468e3733b..b341685df 100644 --- a/studio/lib/payloads/compensations.ts +++ b/studio/lib/payloads/compensations.ts @@ -8,6 +8,13 @@ export interface Benefit { richText: PortableTextBlock[]; } +export interface SalariesPage { + _type: string; + _key: string; + year: number; + salaries: string; +} + export interface CompensationsPage { _createdAt: string; _id: string; diff --git a/studio/schemas/documents/compensations.ts b/studio/schemas/documents/compensations.ts index 881bea522..ab12972fc 100644 --- a/studio/schemas/documents/compensations.ts +++ b/studio/schemas/documents/compensations.ts @@ -5,6 +5,7 @@ import { title } from "../fields/text"; import { bonusesByLocation } from "../objects/compensations/bonusesByLocation"; import { pension } from "../objects/compensations/pension"; import { benefitsByLocation } from "../objects/compensations/benefitsByLocation"; +import { salariesByLocation } from "../objects/compensations/salariesByLocation"; export const compensationsId = "compensations"; @@ -31,7 +32,7 @@ const compensations = defineType({ pension, bonusesByLocation, benefitsByLocation, - // add salary here + salariesByLocation, seo, ], preview: { diff --git a/studio/schemas/objects/compensations/benefitsByLocation.ts b/studio/schemas/objects/compensations/benefitsByLocation.ts index cf06767f1..e49b6a730 100644 --- a/studio/schemas/objects/compensations/benefitsByLocation.ts +++ b/studio/schemas/objects/compensations/benefitsByLocation.ts @@ -40,10 +40,8 @@ export const benefitsByLocation = defineField({ prepare({ location, benefitsGroup }) { const benefitsCount = benefitsGroup ? benefitsGroup.length : 0; return { - title: location - ? `Benefits group for ${location}` - : "No location selected", - subtitle: `Number of benefits: ${benefitsCount}`, + title: location || "No location selected", + subtitle: `${benefitsCount} benefit${benefitsCount > 1 ? "s" : ""}`, }; }, }, diff --git a/studio/schemas/objects/compensations/bonusesByLocation.ts b/studio/schemas/objects/compensations/bonusesByLocation.ts index 93d7aee2a..7e2a443e5 100644 --- a/studio/schemas/objects/compensations/bonusesByLocation.ts +++ b/studio/schemas/objects/compensations/bonusesByLocation.ts @@ -1,6 +1,10 @@ import { defineField } from "sanity"; import { location, locationID } from "../locations"; import { companyLocationNameID } from "studio/schemas/documents/companyLocation"; +import { + DocumentWithLocation, + checkForDuplicateLocations, +} from "./utils/validation"; export const bonusesByLocation = defineField({ name: "bonusesByLocation", @@ -41,55 +45,21 @@ export const bonusesByLocation = defineField({ }, prepare({ averageBonus, location }) { return { - title: `Average Bonus: ${averageBonus || "N/A"}`, - subtitle: `Location: ${location || "No location selected"}`, + title: location || "No location selected", + subtitle: averageBonus || "N/A", }; }, }, }), ], validation: (Rule) => - Rule.custom((bonusesByLocation, context) => { - const duplicateCheck = checkForDuplicateLocations( - bonusesByLocation as BonusEntry[] | undefined, + Rule.custom((bonusesByLocation) => { + const isNotDuplicate: boolean = checkForDuplicateLocations( + bonusesByLocation as DocumentWithLocation[] | undefined, + ); + return ( + isNotDuplicate || + "Each location should be listed only once in the bonuses list. You can assign the same bonus amount to multiple locations, but make sure no location appears more than once." ); - - return duplicateCheck; }), }); - -interface LocationReference { - _ref: string; - _type: string; - title?: string; -} - -interface BonusEntry { - location: LocationReference; - averageBonus: number; -} - -/** - * Checks for duplicate location references in the bonusesByLocation array. - * Ensures each location has a unique bonus entry. - * - * @param {BonusEntry[] | undefined} bonusesByLocation - The array of bonus entries, each with one or more locations. - * @returns {string | true} - Returns an error message if duplicate locations are found, or true if all are unique. - */ -const checkForDuplicateLocations = ( - bonusesByLocation: BonusEntry[] | undefined, -): string | true => { - if (!bonusesByLocation) return true; - - const locationRefs = bonusesByLocation - .map((entry) => entry.location?._ref) - .filter(Boolean); - - const uniqueRefs = new Set(locationRefs); - - if (uniqueRefs.size !== locationRefs.length) { - return "Each location should be listed only once in the bonuses list. You can assign the same bonus amount to multiple locations, but make sure no location appears more than once."; - } - - return true; -}; diff --git a/studio/schemas/objects/compensations/salariesByLocation.ts b/studio/schemas/objects/compensations/salariesByLocation.ts new file mode 100644 index 000000000..70665b70d --- /dev/null +++ b/studio/schemas/objects/compensations/salariesByLocation.ts @@ -0,0 +1,100 @@ +import { defineField } from "sanity"; +import { location, locationID } from "../locations"; +import { + DocumentWithLocation, + checkForDuplicateLocations, +} from "./utils/validation"; +import { companyLocationNameID } from "../../documents/companyLocation"; +import { SalariesInput } from "../../../components/salariesInput/SalariesInput"; +import { SalariesPage } from "../../../lib/payloads/compensations"; + +export const salariesByLocation = defineField({ + name: "salaries", + title: "Salaries by Location", + description: + "Yearly salary data specific to a particular location. Each location should have a unique entry with the yearly salaries for that location.", + type: "array", + of: [ + { + title: "Location Salaries", + description: "Yearly salary data for a specific location", + type: "object", + fields: [ + { + ...location, + description: + "Select the company location for which you are entering the salary information. Each location must be unique.", + validation: (Rule) => Rule.required(), + }, + defineField({ + name: "yearlySalaries", + title: "Yearly Salaries", + description: + "Salary data reflecting salaries given to employees for a given year. ", + type: "array", + of: [ + { + type: "object", + fields: [ + defineField({ + name: "year", + title: "Year", + description: + "The calendar year for which these salaries were in effect", + type: "number", + validation: (Rule) => Rule.required().min(2018), + }), + defineField({ + name: "salaries", + title: "Salaries", + description: + "Salary amounts for each examination year. File upload expects a CSV file (.csv) containing lines of '{year},{salary}', e.g. '2024,600000'.", + type: "string", + components: { + input: SalariesInput, + }, + }), + ], + preview: { + select: { + title: "year", + }, + }, + }, + ], + }), + ], + preview: { + select: { + location: `${locationID}.${companyLocationNameID}`, + yearlySalaries: `yearlySalaries`, + }, + prepare({ location, yearlySalaries }) { + const latestYear = + yearlySalaries && yearlySalaries.length > 0 + ? yearlySalaries.reduce((acc: number, salaries: SalariesPage) => { + if (salaries.year > acc) { + return salaries.year; + } + return acc; + }, yearlySalaries[0].year) + : undefined; + return { + title: location || "No location selected", + subtitle: latestYear ? `Latest year: ${latestYear}` : "N/A", + }; + }, + }, + }, + ], + validation: (Rule) => + Rule.custom((salariesByLocation) => { + const isNotDuplicate: boolean = checkForDuplicateLocations( + salariesByLocation as DocumentWithLocation[] | undefined, + ); + return ( + isNotDuplicate || + "Each location should be listed only once in the salaries list. You can assign the same salary data to multiple locations, but make sure no location appears more than once." + ); + }), +}); diff --git a/studio/utils/result.ts b/studio/utils/result.ts new file mode 100644 index 000000000..a551a07ab --- /dev/null +++ b/studio/utils/result.ts @@ -0,0 +1,54 @@ +/** + * Represents the result of an operation that can either succeed or fail. + * + * @template T The type of the successful result value. + * @template E The type of the error in case of failure. + * + * This type is a union of two possible outcomes: + * 1. A successful result: { ok: true, value: T } + * 2. An error result: { ok: false, error: E } + * + * Usage example: + * ```typescript + * type MyResult = Result; + * const successResult: MyResult = { ok: true, value: 42 }; + * const errorResult: MyResult = { ok: false, error: "Operation failed" }; + * ``` + */ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +/** + * Creates a successful Result object. + * + * @template T The type of the successful result value. + * @template E The type of the error (not used in this function, but part of the Result type). + * @param value The value to be wrapped in a successful Result. + * @returns A Result object indicating success with the provided value. + * + * Usage example: + * ```typescript + * const result = ResultOk(42); + * // result is { ok: true, value: 42 } + * ``` + */ +export function ResultOk(value: T): Result { + return { ok: true, value }; +} + +/** + * Creates an error Result object. + * + * @template T The type of the successful result value (not used in this function, but part of the Result type). + * @template E The type of the error. + * @param error The error to be wrapped in a failed Result. + * @returns A Result object indicating failure with the provided error. + * + * Usage example: + * ```typescript + * const result = ResultError("Operation failed"); + * // result is { ok: false, error: "Operation failed" } + * ``` + */ +export function ResultError(error: E): Result { + return { ok: false, error }; +}