From ef6932d2c99445db67b602a458ab0a71592f3258 Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Fri, 13 Sep 2024 07:18:27 +0000 Subject: [PATCH] v3 - custom string input with character count (#529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): custom string input component with character count Composes the default string input rendering with a simple character count. Initially only used in SEO fields, but can be later applied where applicable. * feat(StringInputWithCharacterCount): max count * feat(StringInputWithCharacterCount): use character count input for more string fields * refactor(schema): rename Rule → rule in validation function * feat(StringInputWithCharacterCount): handle plural form * feat(StringInputWithCharacterCount): make maxCount required and show characters left --- .../StringInputWithCharacterCount.tsx | 44 +++++++++++++++ .../stringInputWithCharacterCount.module.css | 13 +++++ studio/schemas/builders/pageBuilder.ts | 7 ++- studio/schemas/documents/blog.ts | 19 ++++++- studio/schemas/documents/companyInfo.ts | 6 ++ studio/schemas/documents/companyLocation.ts | 6 ++ studio/schemas/documents/navigationManager.ts | 4 +- studio/schemas/documents/post.ts | 12 ++-- studio/schemas/fields/categories.ts | 9 ++- studio/schemas/fields/media.ts | 34 +++++------ studio/schemas/fields/text.ts | 14 ++++- .../compensations/benefitsByLocation.ts | 8 +-- .../compensations/bonusesByLocation.ts | 13 +++-- .../schemas/objects/compensations/pension.ts | 7 ++- .../compensations/salariesByLocation.ts | 8 +-- studio/schemas/objects/footerSection.ts | 14 ++++- studio/schemas/objects/link.ts | 56 +++++++++++-------- studio/schemas/objects/sections/article.ts | 8 ++- .../schemas/objects/sections/callToAction.ts | 2 +- studio/schemas/objects/sections/form.ts | 5 +- studio/schemas/objects/sections/grid.ts | 8 +-- studio/schemas/objects/sections/hero.ts | 13 +++-- studio/schemas/objects/sections/image.ts | 2 +- studio/schemas/objects/sections/logoSalad.ts | 18 ++++-- .../schemas/objects/sections/testimonials.ts | 5 +- studio/schemas/objects/seo.ts | 40 +++++++++---- studio/schemas/objects/socialMedia.ts | 4 +- studio/schemas/objects/testimony.ts | 7 +++ studio/schemas/schemaTypes/slug.ts | 4 +- studioShared/schemas/documents/blogPosts.ts | 4 +- 30 files changed, 279 insertions(+), 115 deletions(-) create mode 100644 studio/components/stringInputWithCharacterCount/StringInputWithCharacterCount.tsx create mode 100644 studio/components/stringInputWithCharacterCount/stringInputWithCharacterCount.module.css diff --git a/studio/components/stringInputWithCharacterCount/StringInputWithCharacterCount.tsx b/studio/components/stringInputWithCharacterCount/StringInputWithCharacterCount.tsx new file mode 100644 index 000000000..9738b3e77 --- /dev/null +++ b/studio/components/stringInputWithCharacterCount/StringInputWithCharacterCount.tsx @@ -0,0 +1,44 @@ +import { Box, Stack, Text } from "@sanity/ui"; +import { StringInputProps } from "sanity"; +import styles from "./stringInputWithCharacterCount.module.css"; + +type StringInputWithCharacterCountProps = StringInputProps & { + maxCount: number; +}; + +const SR_COUNT_HINT_ID = "string-input-with-character-count-input-info"; + +export const StringInputWithCharacterCount = ({ + maxCount, + ...defaultProps +}: StringInputWithCharacterCountProps) => { + const characterCount = defaultProps.value?.length ?? 0; + const charactersLeft = maxCount - characterCount; + const isOverLimit = charactersLeft < 0; + const countText = `${Math.abs(charactersLeft)} character${charactersLeft !== 1 ? "s" : ""} ${isOverLimit ? "over limit" : charactersLeft === maxCount ? "allowed" : "left"}`; + + return ( + + + {defaultProps.renderDefault({ + ...defaultProps, + elementProps: { + ...defaultProps.elementProps, + "aria-describedby": `${defaultProps.elementProps["aria-describedby"]} ${SR_COUNT_HINT_ID}`, + }, + })} + + + {`You can enter up to ${maxCount} character${charactersLeft !== 1 ? "s" : ""}`} + + + {countText} + + + ); +}; diff --git a/studio/components/stringInputWithCharacterCount/stringInputWithCharacterCount.module.css b/studio/components/stringInputWithCharacterCount/stringInputWithCharacterCount.module.css new file mode 100644 index 000000000..21c22f33d --- /dev/null +++ b/studio/components/stringInputWithCharacterCount/stringInputWithCharacterCount.module.css @@ -0,0 +1,13 @@ +.srOnly { + /* 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); +} + +.overLimit { + color: rgb(203, 38, 24) !important; +} diff --git a/studio/schemas/builders/pageBuilder.ts b/studio/schemas/builders/pageBuilder.ts index 0384ae7e0..1bfc7748f 100644 --- a/studio/schemas/builders/pageBuilder.ts +++ b/studio/schemas/builders/pageBuilder.ts @@ -11,6 +11,7 @@ import testimonals from "../objects/sections/testimonials"; import imageSection from "../objects/sections/image"; import grid from "../objects/sections/grid"; import contactForm from "../objects/sections/form"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const pageBuilderID = "pageBuilder"; @@ -25,7 +26,11 @@ const pageBuilder = defineType({ description: "Enter a distinctive name for the dynamic page to help content editors easily identify and manage it. This name is used internally and is not visible on your website.", type: "string", - validation: (Rule) => Rule.required().max(30), + validation: (rule) => rule.required().max(30), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 30 }), + }, }), pageSlug, seo, diff --git a/studio/schemas/documents/blog.ts b/studio/schemas/documents/blog.ts index fba1bb82b..6873609fd 100644 --- a/studio/schemas/documents/blog.ts +++ b/studio/schemas/documents/blog.ts @@ -2,6 +2,7 @@ import { defineField, defineType } from "sanity"; import seo from "../objects/seo"; import { pageSlug } from "../schemaTypes/slug"; import { title } from "../fields/text"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const blogId = "blog"; @@ -16,7 +17,11 @@ const blog = defineType({ description: "Enter a distinctive name for the page to help content editors easily identify and manage it. This name is used internally and is not visible on your website.", type: "string", - validation: (Rule) => Rule.required().max(30), + validation: (rule) => rule.required().max(30), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 30 }), + }, }), pageSlug, seo, @@ -28,7 +33,11 @@ const blog = defineType({ "Enter the label used to refer to all posts regardless of their category. This label will be displayed in the filter section on the main blog page. Examples include 'news', 'stories', or 'posts'.", type: "string", initialValue: "All posts", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required().max(20), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 20 }), + }, }), defineField({ name: "categories", @@ -48,7 +57,11 @@ const blog = defineType({ description: "The name of the category. This will be displayed on the website and used for organizing blog posts.", type: "string", - validation: (Rule) => Rule.required().min(1).max(100), + validation: (rule) => rule.required().min(1).max(100), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 100 }), + }, }), ], }), diff --git a/studio/schemas/documents/companyInfo.ts b/studio/schemas/documents/companyInfo.ts index ec44f7817..7a4233d07 100644 --- a/studio/schemas/documents/companyInfo.ts +++ b/studio/schemas/documents/companyInfo.ts @@ -1,5 +1,6 @@ import { defineType, defineField } from "sanity"; import seo from "../objects/seo"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const companyInfoID = "companyInfo"; @@ -24,6 +25,11 @@ const companyInfo = defineType({ type: "string", title: "Site Name", description: "The name of your website.", + validation: (rule) => rule.max(60), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 60 }), + }, }), defineField({ name: "defaultLanguage", diff --git a/studio/schemas/documents/companyLocation.ts b/studio/schemas/documents/companyLocation.ts index 91352c7c7..ca2a04ed5 100644 --- a/studio/schemas/documents/companyLocation.ts +++ b/studio/schemas/documents/companyLocation.ts @@ -1,4 +1,5 @@ import { defineField, defineType } from "sanity"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const companyLocationID = "companyLocation"; export const companyLocationNameID = "companyLocationName"; @@ -13,6 +14,11 @@ const companyLocation = defineType({ name: companyLocationNameID, type: "string", title: "Location", + validation: (rule) => rule.max(50), + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 50 }), + }, }), ], }); diff --git a/studio/schemas/documents/navigationManager.ts b/studio/schemas/documents/navigationManager.ts index 24f5c3560..817e2cc83 100644 --- a/studio/schemas/documents/navigationManager.ts +++ b/studio/schemas/documents/navigationManager.ts @@ -36,8 +36,8 @@ const navigationManager = defineType({ "Add links to the main menu. These links will appear at the top of your website and help visitors navigate to important sections. The first Call to Action (CTA) will be styled as a primary link button. Note: The order in which you add the links here is how they will be displayed on the website.", type: "array", of: [{ type: linkID }, { type: callToActionFieldID }], - validation: (Rule) => - Rule.custom((links) => { + validation: (rule) => + rule.custom((links) => { if (!Array.isArray(links)) return true; const ctaCount = links.filter( (link) => link._type === callToActionFieldID, diff --git a/studio/schemas/documents/post.ts b/studio/schemas/documents/post.ts index 78ddf0854..62ad9690f 100644 --- a/studio/schemas/documents/post.ts +++ b/studio/schemas/documents/post.ts @@ -20,8 +20,8 @@ const posts = defineType({ description: "Select the date and time when this post will be published.", type: "datetime", initialValue: () => new Date().toISOString(), - validation: (Rule) => - Rule.required().custom((date, context) => { + validation: (rule) => + rule.required().custom((date, context) => { // Ensure date is not undefined or null if (!date) return "The publish date is required."; @@ -57,7 +57,7 @@ const posts = defineType({ components: { input: CategorySelector, }, - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), defineField({ name: "lead", @@ -69,15 +69,15 @@ const posts = defineType({ defineField({ ...richText, description: "Enter the introductory text for the post.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), defineField({ ...image, description: "Upload a featured image for the post.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), ], - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), defineField({ ...richText, diff --git a/studio/schemas/fields/categories.ts b/studio/schemas/fields/categories.ts index ea7c07d9a..d91161216 100644 --- a/studio/schemas/fields/categories.ts +++ b/studio/schemas/fields/categories.ts @@ -1,4 +1,5 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const categoriesId = "categories"; @@ -11,7 +12,11 @@ const categories = defineField({ name: "category", type: "string", title: "Category", - validation: (Rule) => Rule.required().min(1).max(100), + validation: (rule) => rule.required().min(1).max(100), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 100 }), + }, }, ], }); diff --git a/studio/schemas/fields/media.ts b/studio/schemas/fields/media.ts index e1e7fa403..aaf3795e6 100644 --- a/studio/schemas/fields/media.ts +++ b/studio/schemas/fields/media.ts @@ -1,4 +1,5 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export enum ImageAlignment { Left = "left", @@ -15,20 +16,25 @@ const alignmentOptions = [ { title: "Right", value: ImageAlignment.Right }, ]; +const imageAltField = defineField({ + name: "alt", + type: "string", + title: "Alternative Text", + description: + "Provide a description of the image for accessibility. Leave empty if the image is purely decorative.", + validation: (rule) => rule.max(100), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 100 }), + }, +}); + const image = defineField({ name: "image", title: "Image", type: "image", options: { hotspot: true }, - fields: [ - { - name: "alt", - type: "string", - title: "Alternative Text", - description: - "Provide a description of the image for accessibility. Leave empty if the image is purely decorative.", - }, - ], + fields: [imageAltField], }); export const imageExtended = defineField({ @@ -37,13 +43,7 @@ export const imageExtended = defineField({ type: "image", options: { hotspot: true }, fields: [ - { - name: "alt", - type: "string", - title: "Alternative Text", - description: - "Provide a description of the image for accessibility. Leave empty if the image is purely decorative.", - }, + imageAltField, { name: "imageAlignment", title: "Image Alignment", diff --git a/studio/schemas/fields/text.ts b/studio/schemas/fields/text.ts index d0df65def..3f22fde1f 100644 --- a/studio/schemas/fields/text.ts +++ b/studio/schemas/fields/text.ts @@ -1,4 +1,5 @@ import { StringRule, defineField } from "sanity"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; enum titleID { basic = "basicTitle", @@ -22,8 +23,8 @@ const createField = ({ isRequired = false, maxLength = 60, }: CreateFieldProps) => { - const validationRules = (Rule: StringRule) => { - let rules = Rule.max(maxLength); + const validationRules = (rule: StringRule) => { + let rules = rule.max(maxLength); if (isRequired) { rules = rules.required(); } @@ -35,6 +36,10 @@ const createField = ({ title, type: "string", validation: validationRules, + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: maxLength }), + }, }); }; @@ -53,7 +58,10 @@ export const optionalSubtitle = defineField({ name: subtitleID.optional, title: "Subtitle", type: "string", - validation: (Rule) => Rule.max(60), + validation: (rule) => rule.max(60), + components: { + input: (props) => StringInputWithCharacterCount({ ...props, maxCount: 60 }), + }, }); export const richTextID = "richText"; diff --git a/studio/schemas/objects/compensations/benefitsByLocation.ts b/studio/schemas/objects/compensations/benefitsByLocation.ts index 991d8681a..dde5db400 100644 --- a/studio/schemas/objects/compensations/benefitsByLocation.ts +++ b/studio/schemas/objects/compensations/benefitsByLocation.ts @@ -28,7 +28,7 @@ const benefitType = defineField({ layout: BENEFIT_TYPES.length > 5 ? "dropdown" : "radio", }, initialValue: BENEFIT_TYPE_BASIC_VALUE, - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }); export const benefitsByLocation = defineField({ @@ -45,7 +45,7 @@ export const benefitsByLocation = defineField({ ...location, description: "Select the office location for which you are entering benefits information. Each location must be unique.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, defineField({ name: "benefits", @@ -93,8 +93,8 @@ export const benefitsByLocation = defineField({ }, }, ], - validation: (Rule) => - Rule.custom((benefitsByLocation) => { + validation: (rule) => + rule.custom((benefitsByLocation) => { const isNotDuplicate: boolean = checkForDuplicateLocations( benefitsByLocation as DocumentWithLocation[] | undefined, ); diff --git a/studio/schemas/objects/compensations/bonusesByLocation.ts b/studio/schemas/objects/compensations/bonusesByLocation.ts index d71c64847..4228f917d 100644 --- a/studio/schemas/objects/compensations/bonusesByLocation.ts +++ b/studio/schemas/objects/compensations/bonusesByLocation.ts @@ -25,7 +25,7 @@ export const bonusesByLocation = defineField({ ...location, description: "Select the company location for which you are entering the yearly bonus data. Each location must be unique.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, defineField({ name: "yearlyBonuses", @@ -43,7 +43,7 @@ export const bonusesByLocation = defineField({ description: "The calendar year for which this bonus was given", type: "number", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), defineField({ name: "bonus", @@ -51,8 +51,9 @@ export const bonusesByLocation = defineField({ description: "Enter the bonus amount for this year. Ensure the amount is positive and reflective of the compensation package for this location.", type: "number", - validation: (Rule) => - Rule.required() + validation: (rule) => + rule + .required() .min(0) .error("Please enter a positive bonus amount."), }), @@ -95,8 +96,8 @@ export const bonusesByLocation = defineField({ }, }), ], - validation: (Rule) => - Rule.custom((bonusesByLocation) => { + validation: (rule) => + rule.custom((bonusesByLocation) => { const isNotDuplicate: boolean = checkForDuplicateLocations( bonusesByLocation as DocumentWithLocation[] | undefined, ); diff --git a/studio/schemas/objects/compensations/pension.ts b/studio/schemas/objects/compensations/pension.ts index bdaeefa30..86685334a 100644 --- a/studio/schemas/objects/compensations/pension.ts +++ b/studio/schemas/objects/compensations/pension.ts @@ -6,11 +6,12 @@ export const pension = defineField({ type: "number", initialValue: 7, description: `Specify the percentage of the pension provided by Variant for employees. The value should be a positive number and will be used to calculate the pension amount.`, - validation: (Rule) => [ - Rule.min(0) + validation: (rule) => [ + rule + .min(0) .max(100) .error("The pension percentage must be a number between 0 and 100."), - Rule.custom((value, context) => { + rule.custom((value, context) => { if ( context.document?.showSalaryCalculator && (value === undefined || value === null) diff --git a/studio/schemas/objects/compensations/salariesByLocation.ts b/studio/schemas/objects/compensations/salariesByLocation.ts index 134bbd55c..a7aa68c07 100644 --- a/studio/schemas/objects/compensations/salariesByLocation.ts +++ b/studio/schemas/objects/compensations/salariesByLocation.ts @@ -24,7 +24,7 @@ export const salariesByLocation = defineField({ ...location, description: "Select the company location for which you are entering the salary information. Each location must be unique.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, defineField({ name: "yearlySalaries", @@ -42,7 +42,7 @@ export const salariesByLocation = defineField({ description: "The calendar year for which these salaries were in effect", type: "number", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), defineField({ name: "salaries", @@ -87,8 +87,8 @@ export const salariesByLocation = defineField({ }, }, ], - validation: (Rule) => - Rule.custom((salariesByLocation) => { + validation: (rule) => + rule.custom((salariesByLocation) => { const isNotDuplicate: boolean = checkForDuplicateLocations( salariesByLocation as DocumentWithLocation[] | undefined, ); diff --git a/studio/schemas/objects/footerSection.ts b/studio/schemas/objects/footerSection.ts index 57d7aab7e..c3d7e5f17 100644 --- a/studio/schemas/objects/footerSection.ts +++ b/studio/schemas/objects/footerSection.ts @@ -1,7 +1,8 @@ -import { defineType } from "sanity"; +import { defineType, StringInputProps } from "sanity"; import { linkID } from "./link"; import { soMeLinksID } from "../documents/socialMediaProfiles"; import { richText, richTextID } from "../fields/text"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const footerSectionID = { main: "footerSection", @@ -32,7 +33,14 @@ export const footerSection = defineType({ type: "string", description: "Enter the title for this footer section. This will help identify the section within the footer.", - validation: (Rule) => Rule.required().error("Section title is required"), + validation: (rule) => [ + rule.required().error("Section title is required"), + rule.max(60), + ], + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 60 }), + }, }, { name: footerSectionID.type, @@ -47,7 +55,7 @@ export const footerSection = defineType({ ], layout: "dropdown", }, - validation: (Rule) => Rule.required().error("Content type is required"), + validation: (rule) => rule.required().error("Content type is required"), initialValue: SectionType.Content, }, { diff --git a/studio/schemas/objects/link.ts b/studio/schemas/objects/link.ts index 3ed110cd0..388605aa6 100644 --- a/studio/schemas/objects/link.ts +++ b/studio/schemas/objects/link.ts @@ -1,7 +1,8 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; import AnchorSelect from "../../components/AnchorSelect"; import LinkTypeSelector from "../../components/LinkTypeSelector"; import NewTabSelector from "../../components/NewTabSelector"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const linkID = "link"; @@ -34,6 +35,11 @@ export const link = defineField({ title: "Provide a link title", type: "string", description: "Enter the link text that will be displayed on the website.", + validation: (rule) => rule.max(60), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 60 }), + }, }, { name: "linkType", @@ -44,8 +50,8 @@ export const link = defineField({ components: { input: LinkTypeSelector, }, - validation: (Rule) => - Rule.custom((value, context) => { + validation: (rule) => + rule.custom((value, context) => { const parent = context.parent as Parent; if (parent?.linkTitle && !value) { return "Link type is required"; @@ -63,8 +69,8 @@ export const link = defineField({ { type: lazyBlogID() }, { type: lazyCompensationsID() }, ], - validation: (Rule: any) => - Rule.custom((value: any, context: any) => { + validation: (rule) => + rule.custom((value: any, context: any) => { const parent = context.parent as Parent; if ( parent?.linkTitle && @@ -87,29 +93,31 @@ export const link = defineField({ type: "url", description: "Enter the full URL for the external link, including 'https://'. For example, 'https://www.example.com'.", - validation: (Rule) => - Rule.uri({ - scheme: ["http", "https"], - allowRelative: false, - }).custom((value, context) => { - const parent = context.parent as Parent; - if ( - parent?.linkTitle && - parent?.linkType === LinkType.External && - !value - ) { - return "URL is required for external links"; - } - return true; - }), + validation: (rule) => + rule + .uri({ + scheme: ["http", "https"], + allowRelative: false, + }) + .custom((value, context) => { + const parent = context.parent as Parent; + if ( + parent?.linkTitle && + parent?.linkType === LinkType.External && + !value + ) { + return "URL is required for external links"; + } + return true; + }), hidden: ({ parent }) => parent?.linkType !== LinkType.External, }, { name: "email", title: "Enter the email address", type: "string", - validation: (Rule) => - Rule.custom((value: string, context) => { + validation: (rule) => + rule.custom((value: string, context) => { const parent = context.parent as Parent; if ( parent?.linkTitle && @@ -129,8 +137,8 @@ export const link = defineField({ name: "phone", title: "Enter the phone number", type: "string", - validation: (Rule) => - Rule.custom((value: string, context) => { + validation: (rule) => + rule.custom((value: string, context) => { const parent = context.parent as Parent; if ( parent?.linkTitle && diff --git a/studio/schemas/objects/sections/article.ts b/studio/schemas/objects/sections/article.ts index 69bdcc294..e22f0d3dd 100644 --- a/studio/schemas/objects/sections/article.ts +++ b/studio/schemas/objects/sections/article.ts @@ -1,7 +1,8 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; import { richText, title } from "../../../schemas/fields/text"; import { imageExtended } from "../../../schemas/fields/media"; import { link } from "../link"; +import { StringInputWithCharacterCount } from "../../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const articleID = "article"; @@ -14,6 +15,11 @@ export const article = defineField({ name: "tag", title: "Tag", type: "string", + validation: (rule) => rule.max(60), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 60 }), + }, }, title, richText, diff --git a/studio/schemas/objects/sections/callToAction.ts b/studio/schemas/objects/sections/callToAction.ts index e9a701cfc..7ae645494 100644 --- a/studio/schemas/objects/sections/callToAction.ts +++ b/studio/schemas/objects/sections/callToAction.ts @@ -29,7 +29,7 @@ export const callToAction = defineField({ preview: callToActionField.preview, }, ], - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, ], initialValue: { diff --git a/studio/schemas/objects/sections/form.ts b/studio/schemas/objects/sections/form.ts index 78554391c..1b0c5c898 100644 --- a/studio/schemas/objects/sections/form.ts +++ b/studio/schemas/objects/sections/form.ts @@ -21,8 +21,9 @@ export const contactForm = defineField({ styles: [{ title: "Normal", value: "normal" }], }, ], - validation: (Rule) => - Rule.required() + validation: (rule) => + rule + .required() .min(1) .error("Consent Agreement Text is required and cannot be empty."), }, diff --git a/studio/schemas/objects/sections/grid.ts b/studio/schemas/objects/sections/grid.ts index d50038e57..4f8916414 100644 --- a/studio/schemas/objects/sections/grid.ts +++ b/studio/schemas/objects/sections/grid.ts @@ -26,7 +26,7 @@ export const grid = defineField({ title: "Item Title", description: "Title of the grid item, such as the name of an employee.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, { ...richText, @@ -39,7 +39,7 @@ export const grid = defineField({ title: "Item Image", description: "Image of the grid item, such as a photo of an employee.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }, ], preview: { @@ -58,8 +58,8 @@ export const grid = defineField({ }, }, ], - validation: (Rule) => - Rule.required().min(1).error("At least one grid item is required."), + validation: (rule) => + rule.required().min(1).error("At least one grid item is required."), }, ], preview: { diff --git a/studio/schemas/objects/sections/hero.ts b/studio/schemas/objects/sections/hero.ts index c21fed68f..daee8f10c 100644 --- a/studio/schemas/objects/sections/hero.ts +++ b/studio/schemas/objects/sections/hero.ts @@ -1,8 +1,9 @@ // hero.ts -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; import callToActionField from "../../fields/callToActionFields"; import CustomCallToActions from "../../../components/CustomCallToActions"; import { title } from "studio/schemas/fields/text"; +import { StringInputWithCharacterCount } from "../../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const heroID = "hero"; @@ -16,7 +17,11 @@ export const hero = defineField({ name: "description", title: "Description", type: "string", - validation: (Rule) => Rule.max(200), + validation: (rule) => rule.max(200), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 200 }), + }, }, { name: "callToActions", @@ -31,8 +36,8 @@ export const hero = defineField({ preview: callToActionField.preview, }, ], - validation: (Rule) => - Rule.custom((callToActions) => { + validation: (rule) => + rule.custom((callToActions) => { if (!Array.isArray(callToActions)) return true; if (callToActions.length > 2) { return "You can only have two Call to Action links"; diff --git a/studio/schemas/objects/sections/image.ts b/studio/schemas/objects/sections/image.ts index bd9158210..dde7bcb15 100644 --- a/studio/schemas/objects/sections/image.ts +++ b/studio/schemas/objects/sections/image.ts @@ -13,7 +13,7 @@ export const imageSection = defineField({ defineField({ ...image, description: "Upload a featured image for the section.", - validation: (Rule) => Rule.required(), + validation: (rule) => rule.required(), }), ], preview: { diff --git a/studio/schemas/objects/sections/logoSalad.ts b/studio/schemas/objects/sections/logoSalad.ts index 4c04e22b0..5c9314970 100644 --- a/studio/schemas/objects/sections/logoSalad.ts +++ b/studio/schemas/objects/sections/logoSalad.ts @@ -1,6 +1,7 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; import image from "studio/schemas/fields/media"; import { richText } from "studio/schemas/fields/text"; +import { StringInputWithCharacterCount } from "../../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; export const logoSaladID = "logoSalad"; @@ -21,8 +22,14 @@ export const logoSalad = defineField({ type: "string", description: "Required text displayed in a smaller body text style. Use it to provide additional context or details about the logos.", - validation: (Rule) => - Rule.required().error("Logo description is required."), + validation: (rule) => [ + rule.required().error("Logo description is required."), + rule.max(100), + ], + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 100 }), + }, }, { name: "logos", @@ -31,8 +38,9 @@ export const logoSalad = defineField({ "Add a list of logos to display. You must include between 6 and 12 logos.", type: "array", of: [image], - validation: (Rule) => - Rule.min(6) + validation: (rule) => + rule + .min(6) .error("At least 6 logos are required.") .max(12) .error("You can add up to 12 logos.") diff --git a/studio/schemas/objects/sections/testimonials.ts b/studio/schemas/objects/sections/testimonials.ts index bbc24b3dd..66cdf48e5 100644 --- a/studio/schemas/objects/sections/testimonials.ts +++ b/studio/schemas/objects/sections/testimonials.ts @@ -15,8 +15,9 @@ export const testimonals = defineField({ title: "List of Testimonials", type: "array", of: [testimony], - validation: (Rule) => - Rule.required() + validation: (rule) => + rule + .required() .min(1) .max(4) .error("You must have between 1 and 4 testimonials."), diff --git a/studio/schemas/objects/seo.ts b/studio/schemas/objects/seo.ts index 8f6bb4603..010bd39e3 100644 --- a/studio/schemas/objects/seo.ts +++ b/studio/schemas/objects/seo.ts @@ -1,4 +1,5 @@ -import { defineField } from "sanity"; +import { defineField, StringInputProps } from "sanity"; +import { StringInputWithCharacterCount } from "studio/components/stringInputWithCharacterCount/StringInputWithCharacterCount"; const seoFieldID = { title: "seoTitle", @@ -22,12 +23,17 @@ const seo = defineField({ title: "SEO & Social Media Title", description: "Create an engaging title that attracts users on social media and in search results. Keep the title between 15-70 characters for the best results.", - validation: (Rule) => [ - Rule.required() + validation: (rule) => [ + rule + .required() .min(15) .error("A title of minimum 15 characters is required"), - Rule.max(70).error("A title cannot exceed 70 characters"), + rule.max(70), ], + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 70 }), + }, }), defineField({ name: seoFieldID.description, @@ -35,14 +41,22 @@ const seo = defineField({ title: "SEO & Social Media Description", description: "An optional but recommended short description to boost visitor engagement from social media and search engines. Try to keep it between 70-160 characters.", - validation: (Rule) => [ - Rule.min(70).warning( - "A description of at least 70 characters has a higher chance of converting visitors", - ), - Rule.max(160).warning( - "A description of more than 160 characters has a lower chance of converting visitors", - ), + validation: (rule) => [ + rule + .min(70) + .warning( + "A description of at least 70 characters has a higher chance of converting visitors", + ), + rule + .max(160) + .warning( + "A description of more than 160 characters has a lower chance of converting visitors", + ), ], + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 160 }), + }, }), defineField({ name: seoFieldID.keywords, @@ -50,6 +64,10 @@ const seo = defineField({ title: "SEO & Social Media Keywords", description: "Enter targeted keywords to enhance your content’s visibility in search engines and social media platforms. Use relevant and specific keywords that describe your content, helping to attract the right audience and improve your SEO performance", + components: { + input: (props) => + StringInputWithCharacterCount({ ...props, maxCount: 200 }), + }, }), defineField({ name: seoFieldID.image, diff --git a/studio/schemas/objects/socialMedia.ts b/studio/schemas/objects/socialMedia.ts index 5d9943b58..70cbe1a1e 100644 --- a/studio/schemas/objects/socialMedia.ts +++ b/studio/schemas/objects/socialMedia.ts @@ -19,8 +19,8 @@ export const socialMedia = defineType({ name: SocialMediaID.url, type: "url", title: "URL", - validation: (Rule) => - Rule.uri({ + validation: (rule) => + rule.uri({ allowRelative: false, scheme: ["http", "https"], }), diff --git a/studio/schemas/objects/testimony.ts b/studio/schemas/objects/testimony.ts index ac16166a1..7d9cbbfa0 100644 --- a/studio/schemas/objects/testimony.ts +++ b/studio/schemas/objects/testimony.ts @@ -1,5 +1,7 @@ import { richText, title } from "../fields/text"; import image from "../fields/media"; +import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount"; +import { StringInputProps, StringRule } from "sanity"; export const testimony = { name: "testimony", @@ -11,6 +13,11 @@ export const testimony = { name: "subTitle", type: "string", title: "Subtitle", + validation: (rule: StringRule) => rule.max(100), + components: { + input: (props: StringInputProps) => + StringInputWithCharacterCount({ ...props, maxCount: 100 }), + }, }, image, richText, diff --git a/studio/schemas/schemaTypes/slug.ts b/studio/schemas/schemaTypes/slug.ts index c18745250..602211222 100644 --- a/studio/schemas/schemaTypes/slug.ts +++ b/studio/schemas/schemaTypes/slug.ts @@ -47,8 +47,8 @@ function createSlugField(source: string) { .slice(0, SLUG_MAX_LENGTH), isUnique: isSlugUniqueAcrossAllDocuments, }, - validation: (Rule) => - Rule.required().custom((value) => { + validation: (rule) => + rule.required().custom((value) => { if (value?.current === undefined) return true; return ( encodeURIComponent(value.current) === value.current || diff --git a/studioShared/schemas/documents/blogPosts.ts b/studioShared/schemas/documents/blogPosts.ts index 10208b062..0a31ead1b 100644 --- a/studioShared/schemas/documents/blogPosts.ts +++ b/studioShared/schemas/documents/blogPosts.ts @@ -18,8 +18,8 @@ const blogPosts = defineType({ description: "Select the date and time when this post will be published.", type: "datetime", initialValue: () => new Date().toISOString(), - validation: (Rule) => - Rule.required().custom((date, context) => { + validation: (rule) => + rule.required().custom((date, context) => { // Ensure date is not undefined or null if (!date) return "The publish date is required.";