diff --git a/dev/test-studio/plugins/locale-no-nb/bundles/studio.ts b/dev/test-studio/plugins/locale-no-nb/bundles/studio.ts index 83890d2e876..898466e6f76 100644 --- a/dev/test-studio/plugins/locale-no-nb/bundles/studio.ts +++ b/dev/test-studio/plugins/locale-no-nb/bundles/studio.ts @@ -4,6 +4,61 @@ const studioResources: Record = { /* Relative time, just now */ 'relative-time.just-now': 'akkurat nå', + /** --- Calendar (date input, search filters...) --- */ + + /** Action message for navigating to next month */ + 'calendar.action.go-to-next-month': 'Gå til neste måned', + /** Action message for navigating to previous month */ + 'calendar.action.go-to-previous-month': 'Gå til forrige måned', + /** Action message for navigating to next year */ + 'calendar.action.go-to-next-year': 'Gå til neste år', + /** Action message for navigating to previous year */ + 'calendar.action.go-to-previous-year': 'Gå til forrige år', + /** Action message for setting to the current time */ + 'calendar.action.set-to-current-time': 'Sett til nå', + /** Action message for selecting the hour */ + 'calendar.action.select-hour': 'Velg time', + /** Action message for selecting the minute */ + 'calendar.action.select-minute': 'Velg minutt', + + /** Month names */ + 'calendar.month-names.january': 'Januar', + 'calendar.month-names.february': 'Februar', + 'calendar.month-names.march': 'Mars', + 'calendar.month-names.april': 'April', + 'calendar.month-names.may': 'Mai', + 'calendar.month-names.june': 'Juni', + 'calendar.month-names.july': 'Juli', + 'calendar.month-names.august': 'August', + 'calendar.month-names.september': 'September', + 'calendar.month-names.october': 'Oktober', + 'calendar.month-names.november': 'November', + 'calendar.month-names.december': 'Desember', + + /** Short weekday names */ + 'calendar.weekday-names.short.monday': 'Man', + 'calendar.weekday-names.short.tuesday': 'Tir', + 'calendar.weekday-names.short.wednesday': 'Ons', + 'calendar.weekday-names.short.thursday': 'Tor', + 'calendar.weekday-names.short.friday': 'Fre', + 'calendar.weekday-names.short.saturday': 'Lør', + 'calendar.weekday-names.short.sunday': 'Søn', + + /* Label for navigating the calendar to "today", without _selecting_ today. Short form, eg `Today`, not `Go to today` */ + 'calendar.action.go-to-today': 'I dag', + + /* Accessibility label for navigating the calendar to "today", without _selecting_ today */ + 'calendar.action.go-to-today-aria-label': 'Gå til i dag', + + /** Label for selecting a hour preset. Receives a `time` param as a string on hh:mm format and a `date` param as a Date instance denoting the preset date */ + 'calendar.action.set-to-time-preset': '{{time}} on {{date, datetime}}', + + /** Label for switch that controls whether or not to include time in given timestamp */ + 'calendar.action.include-time-label': 'Med klokkeslett', + + /** Error message displayed in calendar when entered date is not the correct format */ + 'calendar.error.must-be-in-format': 'Må være i formatet {{exampleDate}}', + /** --- Review Changes --- */ /** Title for the Review Changes pane */ @@ -112,49 +167,6 @@ const studioResources: Record = { /** Action message for generating the slug */ 'inputs.slug.action.generate': `Generer`, - /** --- DateTime (and Date) Input --- */ - - /** Action message for navigating to next month */ - 'inputs.datetime.calendar.action.go-to-next-month': 'Gå til forrige måned', - /** Action message for navigating to previous month */ - 'inputs.datetime.calendar.action.go-to-previous-month': 'Gå til neste måned', - /** Action message for navigating to next year */ - 'inputs.datetime.calendar.action.go-to-next-year': 'Gå til neste år', - /** Action message for navigating to previous year */ - 'inputs.datetime.calendar.action.go-to-previous-year': 'Gå til forrige år', - /** Action message for setting to the current time */ - 'inputs.datetime.calendar.action.set-to-current-time': 'Sett til nå', - /** Action message for selecting the hour */ - 'inputs.datetime.calendar.action.select-hour': 'Velg time', - /** Action message for selecting the minute */ - 'inputs.datetime.calendar.action.select-minute': 'Velg minutt', - - /** Month names */ - 'inputs.datetime.calendar.month-names.january': 'Januar', - 'inputs.datetime.calendar.month-names.february': 'Februar', - 'inputs.datetime.calendar.month-names.march': 'Mars', - 'inputs.datetime.calendar.month-names.april': 'April', - 'inputs.datetime.calendar.month-names.may': 'Mai', - 'inputs.datetime.calendar.month-names.june': 'Juni', - 'inputs.datetime.calendar.month-names.july': 'Juli', - 'inputs.datetime.calendar.month-names.august': 'August', - 'inputs.datetime.calendar.month-names.september': 'September', - 'inputs.datetime.calendar.month-names.october': 'Oktober', - 'inputs.datetime.calendar.month-names.november': 'November', - 'inputs.datetime.calendar.month-names.december': 'Desember', - - /** Short weekday names */ - 'inputs.datetime.calendar.weekday-names.short.monday': 'Man', - 'inputs.datetime.calendar.weekday-names.short.tuesday': 'Tir', - 'inputs.datetime.calendar.weekday-names.short.wednesday': 'Ons', - 'inputs.datetime.calendar.weekday-names.short.thursday': 'Tor', - 'inputs.datetime.calendar.weekday-names.short.friday': 'Fre', - 'inputs.datetime.calendar.weekday-names.short.saturday': 'Lør', - 'inputs.datetime.calendar.weekday-names.short.sunday': 'Søn', - - /** Label for selecting a hour preset. Receives a `time` param as a string on hh:mm format and a `date` param as a Date instance denoting the preset date */ - 'inputs.datetime.calendar.action.set-to-time-preset': '{{time}} on {{date, datetime}}', - /** --- File (Image, File and ImageTool) Inputs --- */ /** Open image edit dialog */ @@ -532,6 +544,21 @@ const studioResources: Record = { /** Label for when no document types matching the filter are found */ 'search.document-types-no-matches-found': 'Ingen dokumenttyper funnet', + /** Label for the "Best match" search ordering type */ + 'search.ordering.best-match-label': 'Beste treff', + + /** Label for the "Created: Oldest first" search ordering type */ + 'search.ordering.created-ascending-label': 'Opprettet: Eldste først', + + /** Label for the "Created: Newest first" search ordering type */ + 'search.ordering.created-descending-label': 'Opprettet: Nyeste først', + + /** Label for the "Updated: Oldest first" search ordering type */ + 'search.ordering.updated-ascending-label': 'Oppdatert: Eldste først', + + /** Label for the "Updated: Newest first" search ordering type */ + 'search.ordering.updated-descending-label': 'Oppdatert: Nyeste først', + /** Accessibility label for action to clear all currently applied document type filters */ 'search.action.clear-type-filters-aria-label': 'Fjern valgte filtre', @@ -551,7 +578,7 @@ const studioResources: Record = { 'search.filter-field-tooltip-name': 'Feltnavn', /** Label for "field description" shown in tooltip when navigating list of possible fields to filter */ - 'search.filter-field-tooltip-description': 'Feltopplysninger', + 'search.filter-field-tooltip-description': 'Feltbeskrivelse', /** Label for "Used in document types", a list of the document types a field appears in. Shown in tooltip when navigating list of possible fields to filter */ 'search.filter-field-tooltip-used-in-document-types': 'Brukt i dokumenttyper', @@ -575,10 +602,10 @@ const studioResources: Record = { 'search.filter-number-value-placeholder': 'Verdi', /** Placeholder value for minimum numeric value filter */ - 'search.filter-number-min-value-placeholder': 'Min verdi', + 'search.filter-number-min-value-placeholder': 'Minimumverdi', /** Placeholder value for maximum numeric value filter */ - 'search.filter-number-max-value-placeholder': 'Maks verdi', + 'search.filter-number-max-value-placeholder': 'Maksverdi', /** Label/placeholder prompting user to select one of the predefined, allowed values for a string field */ 'search.filter-string-value-select-predefined-value': 'Velg…', @@ -586,12 +613,306 @@ const studioResources: Record = { /** Label for the action of clearing the currently selected asset in an image/file filter */ 'search.filter-asset-clear': 'Fjern', + /** Label for the action of changing from one image to a different image in asset search filter */ + 'search.filter-asset-change_image': 'Bytt bilde', + + /** Label for the action of changing from one file to a different file in asset search filter */ + 'search.filter-asset-change_file': 'Bytt fil', + + /** Label for the action of selecting an image in asset search filter */ + 'search.filter-asset-select_image': 'Velg bilde', + + /** Label for the action of selecting a file in asset search filter */ + 'search.filter-asset-select_file': 'Velg fil', + /** Label for the action of clearing the currently selected document in a reference filter */ 'search.filter-reference-clear': 'Fjern', - /** Label for search value in a range of numbers */ - // @todo Part of `arrayOperators` - needs `` refactoring - 'search.filter-number-items-range': `{{min}} → {{max}} elementer`, + /** Accessibility label for selecting start date on the date range search filter */ + 'search.filter-date-range-start-date-aria-label': 'Fra dato', + + /** Accessibility label for selecting end date on the date range search filter */ + 'search.filter-date-range-end-date-aria-label': 'Til dato', + + /** Accessibility label for the input value (days/months/years) when adding "X days ago" search filter */ + 'search.filter-date-value-aria-label': 'Enhetsverdi', + + /** Accessibility label for selecting the unit (day/month/year) when adding "X days ago" search filter */ + 'search.filter-date-unit-aria-label': 'Velg enhet', + + /** + * Label for "Days"/"Months"/"Years" when selecting it as unit in "X days ago" search filter. + * Capitalized, as it would be listed in a dropdown. + */ + 'search.filter-date-unit_days': 'Dager', + 'search.filter-date-unit_months': 'Måneder', + 'search.filter-date-unit_years': 'År', + + /** + * Individual search operators. + * + * The `name` variant is the form we use when the user is building a query, and selecting from a + * list of available operators for a field. Keep in mind that since the user knows what the field + * represents, we do not need to contextualize too much, and that the user may not be a developer + * eg prefer "quantity is" over "array has length". Additionally, (if applicable in language) use + * lowercased names. + * + * The `description` variant is the form shown once the filter has enough information to apply, + * and is shown in the list of applied filters. It is passed components that _should_ be used to + * compose the filter string, and to format them correctly: + * + * `` - eg "Bird species", "Category", "Date of birth" + * `operator text` - eg "has ≤", "includes", "is" + * `{{value}}` - eg "Hawk", "Sparrow", "Eagle" + * + * Where applicable, a `count` is passed, allowing you to pluralize where needed, by using + * suffixes such as `_zero`, `_one`, `_other` etc. + * + * Prefer (reasonable) brevity since many filters may be applied. For instance: + * ` has ≤ ` may be better than + * ` has less than eller lik ` + **/ + /* Array should have a count the given filter value */ + 'search.operator.array-count-equal.name': 'antall er', + 'search.operator.array-count-equal.description_one': + ' har {{count}} element', + 'search.operator.array-count-equal.description_other': + ' har {{count}} elementer', + /* Array should have a count greater than given filter value */ + 'search.operator.array-count-gt.name': 'antall større enn', + 'search.operator.array-count-gt.description_one': + ' har > {{count}} element', + 'search.operator.array-count-gt.description_other': + ' har > {{count}} elementer', + /* Array should have a count greater than or the given filter value */ + 'search.operator.array-count-gte.name': 'antall større enn eller lik', + 'search.operator.array-count-gte.description_one': + ' har ≥ {{count}} element', + 'search.operator.array-count-gte.description_other': + ' har ≥ {{count}} elementer', + /* Array should have a count less than given filter value */ + 'search.operator.array-count-lt.name': 'antall mindre enn', + 'search.operator.array-count-lt.description_one': + ' har < {{count}} element', + 'search.operator.array-count-lt.description_other': + ' har < {{count}} elementer', + /* Array should have a count less than or the given filter value */ + 'search.operator.array-count-lte.name': 'antall mindre enn eller lik', + 'search.operator.array-count-lte.description_one': + ' har ≤ {{count}} element', + 'search.operator.array-count-lte.description_other': + ' har ≤ {{count}} elementer', + /* Array should have a count not the given filter value */ + 'search.operator.array-count-not-equal.name': 'antall er ikke', + 'search.operator.array-count-not-equal.description_one': + ' antall er ikke {{count}} element', + 'search.operator.array-count-not-equal.description_other': + ' antall er ikke {{count}} elementer', + /** + * Array should have a count within the range of given filter values. + * Gets passed `{{from}}` and `{{to}}` values. + **/ + 'search.operator.array-count-range.name': 'antall er mellom', + 'search.operator.array-count-range.description': + ' har mellom {{from}} → {{to}} elementer', + /* Array should include the given value */ + 'search.operator.array-list-includes.name': 'inneholder', + 'search.operator.array-list-includes.description': + ' inneholder {{value}}', + /* Array should not include the given value */ + 'search.operator.array-list-not-includes.name': 'inneholder ikke', + 'search.operator.array-list-not-includes.description': + ' inneholder ikke {{value}}', + /* Array should include the given reference */ + 'search.operator.array-reference-includes.name': 'inneholder', + 'search.operator.array-reference-includes.description': + ' inneholder {{value}}', + /* Array should not include the given reference */ + 'search.operator.array-reference-not-includes.name': 'inneholder ikke', + 'search.operator.array-reference-not-includes.description': + ' inneholder ikke {{value}}', + /* Asset (file) should be the selected asset */ + 'search.operator.asset-file-equal.name': 'er', + 'search.operator.asset-file-equal.description': + ' er {{value}}', + /* Asset (file) should not be the selected asset */ + 'search.operator.asset-file-not-equal.name': 'er ikke', + 'search.operator.asset-file-not-equal.description': + ' er ikke {{value}}', + /* Asset (image) should be the selected asset */ + 'search.operator.asset-image-equal.name': 'er', + 'search.operator.asset-image-equal.description': + ' er {{value}}', + /* Asset (image) should not be the selected asset */ + 'search.operator.asset-image-not-equal.name': 'er ikke', + 'search.operator.asset-image-not-equal.description': + ' er ikke {{value}}', + /** + * Boolean value should be the given filter value (true/false). + * Context passed is `true` and `false`, allowing for more specific translations: + * - `search.operator.boolean-equal.description_true` + * - `search.operator.boolean-equal.description_false` + */ + 'search.operator.boolean-equal.name': 'er', + 'search.operator.boolean-equal.description': + ' er {{value}}', + /* Date should be after (later than) given filter value */ + 'search.operator.date-after.name': 'etter', + 'search.operator.date-after.description': + ' er etter {{value}}', + /* Date should be before (earlier than) given filter value */ + 'search.operator.date-before.name': 'før', + 'search.operator.date-before.description': + ' er før {{value}}', + /* Date should be the given filter value */ + 'search.operator.date-equal.name': 'er', + 'search.operator.date-equal.description': + ' er {{value}}', + /* Date should be within the given filter value range (eg "within the last X days") */ + 'search.operator.date-last.name': 'siste', + 'search.operator.date-last.description': + ' er innenfor siste {{value}}', + /* Date should not be the given filter value */ + 'search.operator.date-not-equal.name': 'er ikke', + 'search.operator.date-not-equal.description': + ' er ikke {{value}}', + /* Date should be within the range of given filter values */ + 'search.operator.date-range.name': 'er mellom', + 'search.operator.date-range.description': ' er mellom ', + /* Date and time should be after (later than) given filter value */ + 'search.operator.date-time-after.name': 'etter', + 'search.operator.date-time-after.description': + ' er etter {{value}}', + /* Date and time should be before (earlier than) given filter value */ + 'search.operator.date-time-before.name': 'før', + 'search.operator.date-time-before.description': + ' er før {{value}}', + /* Date and time should be the given filter value */ + 'search.operator.date-time-equal.name': 'er', + 'search.operator.date-time-equal.description': + ' er {{value}}', + /* Date and time should be within the given filter value range (eg "within the last X days") */ + 'search.operator.date-time-last.name': 'siste', + 'search.operator.date-time-last.description': + ' er innenfor siste {{value}}', + /* Date and time should not be the given filter value */ + 'search.operator.date-time-not-equal.name': 'er ikke', + 'search.operator.date-time-not-equal.description': + ' er ikke {{value}}', + /* Date and time should be within the range of given filter values */ + 'search.operator.date-time-range.name': 'er mellom', + 'search.operator.date-time-range.description': ' er mellom ', + /* Value should be defined */ + 'search.operator.defined.name': 'er definert', + 'search.operator.defined.description': ' er definert', + /* Value should not be defined */ + 'search.operator.not-defined.name': 'ikke definert', + 'search.operator.not-defined.description': + ' er ikke definert', + /* Number should be the given filter value */ + 'search.operator.number-equal.name': 'er', + 'search.operator.number-equal.description': + ' er {{value}}', + /* Number should be greater than given filter value */ + 'search.operator.number-gt.name': 'større enn', + 'search.operator.number-gt.description': + ' > {{value}}', + /* Number should be greater than or the given filter value */ + 'search.operator.number-gte.name': 'større enn eller lik', + 'search.operator.number-gte.description': + ' {{value}}', + /* Number should be less than given filter value */ + 'search.operator.number-lt.name': 'mindre enn', + 'search.operator.number-lt.description': + ' < {{value}}', + /* Number should be less than or the given filter value */ + 'search.operator.number-lte.name': 'mindre enn eller lik', + 'search.operator.number-lte.description': + ' {{value}}', + /* Number should not be the given filter value */ + 'search.operator.number-not-equal.name': 'er ikke', + 'search.operator.number-not-equal.description': + ' er ikke {{value}}', + /* Number should be within the range of given filter values */ + 'search.operator.number-range.name': 'er mellom', + 'search.operator.number-range.description': + ' er mellom {{from}} → {{to}}', + /* Portable Text should be the given filter value */ + 'search.operator.portable-text-equal.name': 'er', + 'search.operator.portable-text-equal.description': + ' er {{value}}', + /* Portable Text should contain the given filter value */ + 'search.operator.portable-text-contains.name': 'inneholder', + 'search.operator.portable-text-contains.description': + ' inneholder {{value}}', + /* Portable Text should not be the given filter value */ + 'search.operator.portable-text-not-equal.name': 'er ikke', + 'search.operator.portable-text-not-equal.description': + ' er ikke {{value}}', + /* Portable Text should not contain the given filter value */ + 'search.operator.portable-text-not-contains.name': 'inneholder ikke', + 'search.operator.portable-text-not-contains.description': + ' inneholder ikke {{value}}', + /* Reference should be the given document */ + 'search.operator.reference-equal.name': 'er', + 'search.operator.reference-equal.description': + ' er {{value}}', + /* Reference should not be the given document */ + 'search.operator.reference-not-equal.name': 'er ikke', + 'search.operator.reference-not-equal.description': + ' er ikke {{value}}', + /* References the given asset (file) */ + 'search.operator.reference-asset-file.name': 'fil', + 'search.operator.reference-asset-file.description': + ' {{value}}', + /* References the given asset (image) */ + 'search.operator.reference-asset-image.name': 'bilde', + 'search.operator.reference-asset-image.description': + ' {{value}}', + /* References the given document */ + 'search.operator.reference-document.name': 'dokument', + 'search.operator.reference-document.description': + ' {{value}}', + /* Slug equals the given filter value */ + 'search.operator.slug-equal.name': 'er', + 'search.operator.slug-equal.description': + ' er {{value}}', + /* Slug contains the given value */ + 'search.operator.slug-contains.name': 'inneholder', + 'search.operator.slug-contains.description': + ' inneholder {{value}}', + /* Slug does not equal the given filter value */ + 'search.operator.slug-not-equal.name': 'er ikke', + 'search.operator.slug-not-equal.description': + ' er ikke {{value}}', + /* Slug does not contain the given value */ + 'search.operator.slug-not-contains.name': 'inneholder ikke', + 'search.operator.slug-not-contains.description': + ' inneholder ikke {{value}}', + /* String equals the given filter value */ + 'search.operator.string-equal.name': 'er', + 'search.operator.string-equal.description': + ' er {{value}}', + /* String equals one of the predefined allowed values */ + 'search.operator.string-list-equal.name': 'er', + 'search.operator.string-list-equal.description': + ' er {{value}}', + /* String does not equal one of the predefined allowed values */ + 'search.operator.string-list-not-equal.name': 'er ikke', + 'search.operator.string-list-not-equal.description': + ' er ikke {{value}}', + /* String contains the given filter value */ + 'search.operator.string-contains.name': 'inneholder', + 'search.operator.string-contains.description': + ' inneholder {{value}}', + /* String does not equal the given filter value */ + 'search.operator.string-not-equal.name': 'er ikke', + 'search.operator.string-not-equal.description': + ' er ikke {{value}}', + /* String does not contain the given filter value */ + 'search.operator.string-not-contains.name': 'inneholder ikke', + 'search.operator.string-not-contains.description': + ' inneholder ikke {{value}}', /** Title label for when no search results are found */ 'search.no-results-title': 'Ingen resultater funnet', diff --git a/package.json b/package.json index 28ae4b3c4b0..2b9bddf086e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "deploy:test": "yarn build && cd dev/test-studio && sanity deploy", "dev": "yarn start", "dev:i18n": "SANITY_STUDIO_DEBUG_I18N=true yarn start", + "dev:i18n:reverse": "SANITY_STUDIO_DEBUG_I18N=reverse yarn start", "dev:design-studio": "yarn --cwd dev/design-studio dev", "dev:starter-studio": "yarn --cwd dev/starter-studio dev", "dev:strict-studio": "yarn --cwd dev/strict-studio dev", diff --git a/packages/sanity/src/core/form/inputs/DateInputs/utils.ts b/packages/sanity/src/core/form/inputs/DateInputs/utils.ts index 0708d975ad5..4ae38bb8bda 100644 --- a/packages/sanity/src/core/form/inputs/DateInputs/utils.ts +++ b/packages/sanity/src/core/form/inputs/DateInputs/utils.ts @@ -8,37 +8,36 @@ export function getCalendarLabels( t: (key: string, values?: Record) => string, ): CalendarLabels { return { - goToNextMonth: t('inputs.datetime.calendar.action.go-to-next-month'), - goToPreviousMonth: t('inputs.datetime.calendar.action.go-to-previous-month'), - goToNextYear: t('inputs.datetime.calendar.action.go-to-next-year'), - goToPreviousYear: t('inputs.datetime.calendar.action.go-to-previous-year'), - setToCurrentTime: t('inputs.datetime.calendar.action.set-to-current-time'), - selectHour: t('inputs.datetime.calendar.action.select-hour'), - selectMinute: t('inputs.datetime.calendar.action.select-minute'), + goToNextMonth: t('calendar.action.go-to-next-month'), + goToPreviousMonth: t('calendar.action.go-to-previous-month'), + goToNextYear: t('calendar.action.go-to-next-year'), + goToPreviousYear: t('calendar.action.go-to-previous-year'), + setToCurrentTime: t('calendar.action.set-to-current-time'), + selectHour: t('calendar.action.select-hour'), + selectMinute: t('calendar.action.select-minute'), monthNames: [ - t('inputs.datetime.calendar.month-names.january'), - t('inputs.datetime.calendar.month-names.february'), - t('inputs.datetime.calendar.month-names.march'), - t('inputs.datetime.calendar.month-names.april'), - t('inputs.datetime.calendar.month-names.may'), - t('inputs.datetime.calendar.month-names.june'), - t('inputs.datetime.calendar.month-names.july'), - t('inputs.datetime.calendar.month-names.august'), - t('inputs.datetime.calendar.month-names.september'), - t('inputs.datetime.calendar.month-names.october'), - t('inputs.datetime.calendar.month-names.november'), - t('inputs.datetime.calendar.month-names.december'), + t('calendar.month-names.january'), + t('calendar.month-names.february'), + t('calendar.month-names.march'), + t('calendar.month-names.april'), + t('calendar.month-names.may'), + t('calendar.month-names.june'), + t('calendar.month-names.july'), + t('calendar.month-names.august'), + t('calendar.month-names.september'), + t('calendar.month-names.october'), + t('calendar.month-names.november'), + t('calendar.month-names.december'), ], weekDayNamesShort: [ - t('inputs.datetime.calendar.weekday-names.short.monday'), - t('inputs.datetime.calendar.weekday-names.short.tuesday'), - t('inputs.datetime.calendar.weekday-names.short.wednesday'), - t('inputs.datetime.calendar.weekday-names.short.thursday'), - t('inputs.datetime.calendar.weekday-names.short.friday'), - t('inputs.datetime.calendar.weekday-names.short.saturday'), - t('inputs.datetime.calendar.weekday-names.short.sunday'), + t('calendar.weekday-names.short.monday'), + t('calendar.weekday-names.short.tuesday'), + t('calendar.weekday-names.short.wednesday'), + t('calendar.weekday-names.short.thursday'), + t('calendar.weekday-names.short.friday'), + t('calendar.weekday-names.short.saturday'), + t('calendar.weekday-names.short.sunday'), ], - setToTimePreset: (time, date) => - t('inputs.datetime.calendar.action.set-to-time-preset', {time, date}), + setToTimePreset: (time, date) => t('calendar.action.set-to-time-preset', {time, date}), } } diff --git a/packages/sanity/src/core/hooks/__tests__/useUnitFormatter.test.tsx b/packages/sanity/src/core/hooks/__tests__/useUnitFormatter.test.tsx new file mode 100644 index 00000000000..2f316fb9452 --- /dev/null +++ b/packages/sanity/src/core/hooks/__tests__/useUnitFormatter.test.tsx @@ -0,0 +1,165 @@ +import React, {type ReactElement} from 'react' +import {ThemeProvider, studioTheme} from '@sanity/ui' +import {renderHook} from '@testing-library/react' +import {LocaleProviderBase, usEnglishLocale} from '../../i18n' +import {prepareI18n} from '../../i18n/i18nConfig' +import {studioDefaultLocaleResources} from '../../i18n/bundles/studio' +import {FormattableMeasurementUnit, useUnitFormatter} from '../useUnitFormatter' + +describe('useUnitFormatter', () => { + const {i18next} = prepareI18n({ + projectId: 'test', + dataset: 'test', + name: 'test', + i18n: {bundles: [studioDefaultLocaleResources]}, + }) + + const wrapper = ({children}: {children: ReactElement}) => ( + + + {children} + + + ) + + beforeAll(() => i18next.init()) + beforeEach(() => i18next.changeLanguage('en-US')) + + it('formats with long units as default', () => { + const {result} = renderHook(() => useUnitFormatter()(1, 'meter'), {wrapper}) + expect(result.current).toBe('1 meter') + }) + + it('formats singular/plural correctly', () => { + const {result} = renderHook(() => useUnitFormatter()(2, 'meter'), {wrapper}) + expect(result.current).toBe('2 meters') + }) + + it('can be configured to use short units', () => { + const {result} = renderHook(() => useUnitFormatter({unitDisplay: 'short'})(13, 'foot'), { + wrapper, + }) + expect(result.current).toBe('13 ft') + }) + + it('can be configured to use narrow units', () => { + const {result} = renderHook(() => useUnitFormatter({unitDisplay: 'narrow'})(13, 'foot'), { + wrapper, + }) + expect(result.current).toBe('13′') + }) + + it('respects active locale', async () => { + await i18next.changeLanguage('fr-FR') + const {result} = renderHook(() => useUnitFormatter()(2, 'meter'), {wrapper}) + expect(result.current).toBe('2 mètres') + }) + + it('can format all defined units', () => { + const { + result: {current: format}, + } = renderHook(() => useUnitFormatter({unitDisplay: 'short'}), {wrapper}) + + const formatted: Partial> = {} + const units: FormattableMeasurementUnit[] = [ + 'acre', + 'bit', + 'byte', + 'celsius', + 'centimeter', + 'day', + 'degree', + 'fahrenheit', + 'fluid-ounce', + 'foot', + 'gallon', + 'gigabit', + 'gigabyte', + 'gram', + 'hectare', + 'hour', + 'inch', + 'kilobit', + 'kilobyte', + 'kilogram', + 'kilometer', + 'liter', + 'megabit', + 'megabyte', + 'meter', + 'mile', + 'mile-scandinavian', + 'milliliter', + 'millimeter', + 'millisecond', + 'minute', + 'month', + 'ounce', + 'percent', + 'petabyte', + 'pound', + 'second', + 'stone', + 'terabit', + 'terabyte', + 'week', + 'yard', + 'year', + ] + + units.forEach((unit, idx) => { + formatted[unit] = format(idx, unit) + }) + + expect(formatted).toMatchObject({ + acre: '0 ac', + bit: '1 bit', + byte: '2 byte', + celsius: '3°C', + centimeter: '4 cm', + day: '5 days', + degree: '6 deg', + fahrenheit: '7°F', + 'fluid-ounce': '8 fl oz', + foot: '9 ft', + gallon: '10 gal', + gigabit: '11 Gb', + gigabyte: '12 GB', + gram: '13 g', + hectare: '14 ha', + hour: '15 hr', + inch: '16 in', + kilobit: '17 kb', + kilobyte: '18 kB', + kilogram: '19 kg', + kilometer: '20 km', + liter: '21 L', + megabit: '22 Mb', + megabyte: '23 MB', + meter: '24 m', + mile: '25 mi', + 'mile-scandinavian': '26 smi', + milliliter: '27 mL', + millimeter: '28 mm', + millisecond: '29 ms', + minute: '30 min', + month: '31 mths', + ounce: '32 oz', + percent: '33%', + petabyte: '34 PB', + pound: '35 lb', + second: '36 sec', + stone: '37 st', + terabit: '38 Tb', + terabyte: '39 TB', + week: '40 wks', + yard: '41 yd', + year: '42 yrs', + }) + }) +}) diff --git a/packages/sanity/src/core/hooks/index.ts b/packages/sanity/src/core/hooks/index.ts index dbaed2d728c..7cf4926b890 100644 --- a/packages/sanity/src/core/hooks/index.ts +++ b/packages/sanity/src/core/hooks/index.ts @@ -12,4 +12,5 @@ export * from './useSyncState' export * from './useTemplates' export * from './useTimeAgo' export * from './useTools' +export * from './useUnitFormatter' export * from './useValidationStatus' diff --git a/packages/sanity/src/core/hooks/useUnitFormatter.ts b/packages/sanity/src/core/hooks/useUnitFormatter.ts new file mode 100644 index 00000000000..174a6df2a2a --- /dev/null +++ b/packages/sanity/src/core/hooks/useUnitFormatter.ts @@ -0,0 +1,99 @@ +import {intlCache} from '../i18n/intlCache' +import {useCurrentLocale} from '../i18n/hooks/useLocale' + +/** + * Options for the `useUnitFormatter` hook + * + * @public + */ +export type UseUnitFormatterOptions = Omit + +/** + * Available measurement units + * + * @public + */ +export type FormattableMeasurementUnit = + | 'acre' + | 'bit' + | 'byte' + | 'celsius' + | 'centimeter' + | 'day' + | 'degree' + | 'fahrenheit' + | 'fluid-ounce' + | 'foot' + | 'gallon' + | 'gigabit' + | 'gigabyte' + | 'gram' + | 'hectare' + | 'hour' + | 'inch' + | 'kilobit' + | 'kilobyte' + | 'kilogram' + | 'kilometer' + | 'liter' + | 'megabit' + | 'megabyte' + | 'meter' + | 'mile' + | 'mile-scandinavian' + | 'milliliter' + | 'millimeter' + | 'millisecond' + | 'minute' + | 'month' + | 'ounce' + | 'percent' + | 'petabyte' + | 'pound' + | 'second' + | 'stone' + | 'terabit' + | 'terabyte' + | 'week' + | 'yard' + | 'year' + +/** + * Returns a formatter with the given options. Function takes a number and the unit to format as + * the second argument. The formatter will yield localized output, based on the users' selected + * locale. + * + * This differs from regular `Intl.NumberFormat` in two ways: + * 1. You do not need to instantiate a new formatter for each unit you want to format + * (still happens behind the scenes, but is memoized) + * 2. The default unit display style (`unitDisplay`) is `long` + * + * @example + * ```ts + * function MyComponent() { + * const format = useUnitFormatter() + * return
{format(2313, 'meter')}
+ * // en-US -> 2,313 meters + * // fr-FR -> 2 313 mètres + * } + * ``` + * + * @param options - Optional options for the unit formatter + * @returns Formatter function + * @public + */ +export function useUnitFormatter( + options: UseUnitFormatterOptions = {}, +): (value: number, unit: FormattableMeasurementUnit) => string { + const currentLocale = useCurrentLocale() + const defaultOptions: Intl.NumberFormatOptions = { + unitDisplay: 'long', + ...options, + style: 'unit', + } + + return function format(value: number, unit: FormattableMeasurementUnit) { + const formatter = intlCache.numberFormat(currentLocale, {...defaultOptions, unit}) + return formatter.format(value) + } +} diff --git a/packages/sanity/src/core/i18n/Translate.tsx b/packages/sanity/src/core/i18n/Translate.tsx index c36256802a2..66fd9a5fb52 100644 --- a/packages/sanity/src/core/i18n/Translate.tsx +++ b/packages/sanity/src/core/i18n/Translate.tsx @@ -19,7 +19,10 @@ const RECOGNIZED_HTML_TAGS = [ 'sup', ] -type ComponentMap = Record< +/** + * @beta + */ +export type TranslateComponentMap = Record< string, ComponentType<{children?: ReactNode}> | keyof JSX.IntrinsicElements > @@ -30,12 +33,13 @@ type ComponentMap = Record< export interface TranslationProps { t: TFunction i18nKey: string + + components?: TranslateComponentMap context?: string - values?: Record - components?: ComponentMap + values?: Record } -function render(tokens: Token[], componentMap: ComponentMap): ReactNode { +function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode { const [head, ...tail] = tokens if (!head) { return null diff --git a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts index 03cc4310d1f..7c7e0bb58f5 100644 --- a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts +++ b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts @@ -13,6 +13,39 @@ describe('simpleParser', () => { {type: 'text', text: 'foo is greater than (>) bar'}, ]) }) + test('< character before opening tag', () => { + expect(simpleParser('what is the <tagName')).toMatchObject([ + {type: 'text', text: 'what is the <'}, + {type: 'tagOpen', name: 'Code'}, + {type: 'text', text: 'tagName'}, + {type: 'tagClose', name: 'Code'}, + ]) + }) + test('> character after opening tag', () => { + expect(simpleParser('what is the >tagName')).toMatchObject([ + {type: 'text', text: 'what is the '}, + {type: 'tagOpen', name: 'Code'}, + {type: 'text', text: '>tagName'}, + {type: 'tagClose', name: 'Code'}, + ]) + }) + test('< character before closing tag', () => { + expect(simpleParser('what is the tagName<')).toMatchObject([ + {type: 'text', text: 'what is the '}, + {type: 'tagOpen', name: 'Code'}, + {type: 'text', text: 'tagName<'}, + {type: 'tagClose', name: 'Code'}, + ]) + }) + test('> character after closing tag', () => { + expect(simpleParser('what is the tagName>')).toMatchObject([ + {type: 'text', text: 'what is the '}, + {type: 'tagOpen', name: 'Code'}, + {type: 'text', text: 'tagName'}, + {type: 'tagClose', name: 'Code'}, + {type: 'text', text: '>'}, + ]) + }) test('self closing tags', () => { expect(simpleParser('foo greater than (>) bar')).toMatchObject([ {type: 'text', text: 'foo '}, diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts index 623d558d287..759baaacab7 100644 --- a/packages/sanity/src/core/i18n/bundles/studio.ts +++ b/packages/sanity/src/core/i18n/bundles/studio.ts @@ -10,6 +10,61 @@ export const studioLocaleStrings = { /* Relative time, just now */ 'relative-time.just-now': 'just now', + /** --- Calendar (date input, search filters...) --- */ + + /** Action message for navigating to next month */ + 'calendar.action.go-to-next-month': 'Go to next month', + /** Action message for navigating to previous month */ + 'calendar.action.go-to-previous-month': 'Go to previous month', + /** Action message for navigating to next year */ + 'calendar.action.go-to-next-year': 'Go to next year', + /** Action message for navigating to previous year */ + 'calendar.action.go-to-previous-year': 'Go to previous year', + /** Action message for setting to the current time */ + 'calendar.action.set-to-current-time': 'Set to current time', + /** Action message for selecting the hour */ + 'calendar.action.select-hour': 'Select hour', + /** Action message for selecting the minute */ + 'calendar.action.select-minute': 'Select minute', + + /** Month names */ + 'calendar.month-names.january': 'January', + 'calendar.month-names.february': 'February', + 'calendar.month-names.march': 'March', + 'calendar.month-names.april': 'April', + 'calendar.month-names.may': 'May', + 'calendar.month-names.june': 'June', + 'calendar.month-names.july': 'July', + 'calendar.month-names.august': 'August', + 'calendar.month-names.september': 'September', + 'calendar.month-names.october': 'October', + 'calendar.month-names.november': 'November', + 'calendar.month-names.december': 'December', + + /** Short weekday names */ + 'calendar.weekday-names.short.monday': 'Mon', + 'calendar.weekday-names.short.tuesday': 'Tue', + 'calendar.weekday-names.short.wednesday': 'Wed', + 'calendar.weekday-names.short.thursday': 'Thu', + 'calendar.weekday-names.short.friday': 'Fri', + 'calendar.weekday-names.short.saturday': 'Sat', + 'calendar.weekday-names.short.sunday': 'Sun', + + /* Label for navigating the calendar to "today", without _selecting_ today. Short form, eg `Today`, not `Go to today` */ + 'calendar.action.go-to-today': 'Today', + + /* Accessibility label for navigating the calendar to "today", without _selecting_ today */ + 'calendar.action.go-to-today-aria-label': 'Go to today', + + /** Label for selecting an hour preset. Receives a `time` param as a string on hh:mm format and a `date` param as a Date instance denoting the preset date */ + 'calendar.action.set-to-time-preset': '{{time}} on {{date, datetime}}', + + /** Label for switch that controls whether or not to include time in given timestamp */ + 'calendar.action.include-time-label': 'Include time', + + /** Error message displayed in calendar when entered date is not the correct format */ + 'calendar.error.must-be-in-format': 'Must be in the format {{exampleDate}}', + /** --- Review Changes --- */ /** Title for the Review Changes pane */ @@ -121,49 +176,6 @@ export const studioLocaleStrings = { /** Action message for generating the slug */ 'inputs.slug.action.generate': `Generate`, - /** --- DateTime (and Date) Input --- */ - - /** Action message for navigating to next month */ - 'inputs.datetime.calendar.action.go-to-next-month': 'Go to next month', - /** Action message for navigating to previous month */ - 'inputs.datetime.calendar.action.go-to-previous-month': 'Go to previous month', - /** Action message for navigating to next year */ - 'inputs.datetime.calendar.action.go-to-next-year': 'Go to next year', - /** Action message for navigating to previous year */ - 'inputs.datetime.calendar.action.go-to-previous-year': 'Go to previous year', - /** Action message for setting to the current time */ - 'inputs.datetime.calendar.action.set-to-current-time': 'Set to current time', - /** Action message for selecting the hour */ - 'inputs.datetime.calendar.action.select-hour': 'Select hour', - /** Action message for selecting the minute */ - 'inputs.datetime.calendar.action.select-minute': 'Select minute', - - /** Month names */ - 'inputs.datetime.calendar.month-names.january': 'January', - 'inputs.datetime.calendar.month-names.february': 'February', - 'inputs.datetime.calendar.month-names.march': 'March', - 'inputs.datetime.calendar.month-names.april': 'April', - 'inputs.datetime.calendar.month-names.may': 'May', - 'inputs.datetime.calendar.month-names.june': 'June', - 'inputs.datetime.calendar.month-names.july': 'July', - 'inputs.datetime.calendar.month-names.august': 'August', - 'inputs.datetime.calendar.month-names.september': 'September', - 'inputs.datetime.calendar.month-names.october': 'October', - 'inputs.datetime.calendar.month-names.november': 'November', - 'inputs.datetime.calendar.month-names.december': 'December', - - /** Short weekday names */ - 'inputs.datetime.calendar.weekday-names.short.monday': 'Mon', - 'inputs.datetime.calendar.weekday-names.short.tuesday': 'Tue', - 'inputs.datetime.calendar.weekday-names.short.wednesday': 'Wed', - 'inputs.datetime.calendar.weekday-names.short.thursday': 'Thu', - 'inputs.datetime.calendar.weekday-names.short.friday': 'Fri', - 'inputs.datetime.calendar.weekday-names.short.saturday': 'Sat', - 'inputs.datetime.calendar.weekday-names.short.sunday': 'Sun', - - /** Label for selecting a hour preset. Receives a `time` param as a string on hh:mm format and a `date` param as a Date instance denoting the preset date */ - 'inputs.datetime.calendar.action.set-to-time-preset': '{{time}} on {{date, datetime}}', - /** --- File (Image, File and ImageTool) Inputs --- */ /** Open image edit dialog */ @@ -538,6 +550,21 @@ export const studioLocaleStrings = { /** Label for when no document types matching the filter are found */ 'search.document-types-no-matches-found': `No matches for {{filter}}`, + /** Label for the "Best match" search ordering type */ + 'search.ordering.best-match-label': 'Best match', + + /** Label for the "Created: Oldest first" search ordering type */ + 'search.ordering.created-ascending-label': 'Created: Oldest first', + + /** Label for the "Created: Newest first" search ordering type */ + 'search.ordering.created-descending-label': 'Created: Newest first', + + /** Label for the "Updated: Oldest first" search ordering type */ + 'search.ordering.updated-ascending-label': 'Updated: Oldest first', + + /** Label for the "Updated: Newest first" search ordering type */ + 'search.ordering.updated-descending-label': 'Updated: Newest first', + /** Accessibility label for action to clear all currently applied document type filters */ 'search.action.clear-type-filters-aria-label': 'Clear checked filters', @@ -600,12 +627,308 @@ export const studioLocaleStrings = { /** Label for the action of clearing the currently selected asset in an image/file filter */ 'search.filter-asset-clear': 'Clear', + /** Label for the action of changing from one image to a different image in asset search filter */ + 'search.filter-asset-change_image': 'Change image', + + /** Label for the action of changing from one file to a different file in asset search filter */ + 'search.filter-asset-change_file': 'Change file', + + /** Label for the action of selecting an image in asset search filter */ + 'search.filter-asset-select_image': 'Select image', + + /** Label for the action of selecting a file in asset search filter */ + 'search.filter-asset-select_file': 'Select file', + /** Label for the action of clearing the currently selected document in a reference filter */ 'search.filter-reference-clear': 'Clear', - /** Label for search value in a range of numbers */ - // @todo Part of `arrayOperators` - needs `` refactoring - 'search.filter-number-items-range': `{{min}} → {{max}} items`, + /** Accessibility label for selecting start date on the date range search filter */ + 'search.filter-date-range-start-date-aria-label': 'Start date', + + /** Accessibility label for selecting end date on the date range search filter */ + 'search.filter-date-range-end-date-aria-label': 'End date', + + /** Accessibility label for the input value (days/months/years) when adding "X days ago" search filter */ + 'search.filter-date-value-aria-label': 'Unit value', + + /** Accessibility label for selecting the unit (day/month/year) when adding "X days ago" search filter */ + 'search.filter-date-unit-aria-label': 'Select unit', + + /** + * Label for "Days"/"Months"/"Years" when selecting it as unit in "X days ago" search filter. + * Capitalized, as it would be listed in a dropdown. + */ + 'search.filter-date-unit_days': 'Days', + 'search.filter-date-unit_months': 'Months', + 'search.filter-date-unit_years': 'Years', + + /** + * Individual search operators. + * + * The `name` variant is the form we use when the user is building a query, and selecting from a + * list of available operators for a field. Keep in mind that since the user knows what the field + * represents, we do not need to contextualize too much, and that the user may not be a developer + * eg prefer "quantity is" over "array has length". Additionally, (if applicable in language) use + * lowercased names. + * + * The `description` variant is the form shown once the filter has enough information to apply, + * and is shown in the list of applied filters. It is passed components that _should_ be used to + * compose the filter string, and to format them correctly: + * + * `` - eg "Bird species", "Category", "Date of birth" + * `operator text` - eg "has ≤", "includes", "is" + * `{{value}}` - eg "Hawk", "Sparrow", "Eagle" + * + * Where applicable, a `count` is passed, allowing you to pluralize where needed, by using + * suffixes such as `_zero`, `_one`, `_other` etc. + * + * Prefer (reasonable) brevity since many filters may be applied. For instance: + * ` has ≤ ` may be better than + * ` has less than or equal to ` + **/ + /* Array should have a count the given filter value */ + 'search.operator.array-count-equal.name': 'quantity is', + 'search.operator.array-count-equal.description_one': + ' has {{count}} item', + 'search.operator.array-count-equal.description_other': + ' has {{count}} items', + /* Array should have a count greater than given filter value */ + 'search.operator.array-count-gt.name': 'quantity greater than', + 'search.operator.array-count-gt.description_one': + ' has > {{count}} item', + 'search.operator.array-count-gt.description_other': + ' has > {{count}} items', + /* Array should have a count greater than or the given filter value */ + 'search.operator.array-count-gte.name': 'quantity greater than or equal to', + 'search.operator.array-count-gte.description_one': + ' has ≥ {{count}} item', + 'search.operator.array-count-gte.description_other': + ' has ≥ {{count}} items', + /* Array should have a count less than given filter value */ + 'search.operator.array-count-lt.name': 'quantity less than', + 'search.operator.array-count-lt.description_one': + ' has < {{count}} item', + 'search.operator.array-count-lt.description_other': + ' has < {{count}} items', + /* Array should have a count less than or the given filter value */ + 'search.operator.array-count-lte.name': 'quantity less than or equal to', + 'search.operator.array-count-lte.description_one': + ' has ≤ {{count}} item', + 'search.operator.array-count-lte.description_other': + ' has ≤ {{count}} items', + /* Array should have a count not the given filter value */ + 'search.operator.array-count-not-equal.name': 'quantity is not', + 'search.operator.array-count-not-equal.description_one': + ' does not have {{count}} item', + 'search.operator.array-count-not-equal.description_other': + ' does not have {{count}} items', + /** + * Array should have a count within the range of given filter values. + * Gets passed `{{from}}` and `{{to}}` values. + **/ + 'search.operator.array-count-range.name': 'quantity is between', + 'search.operator.array-count-range.description': + ' has between {{from}} → {{to}} items', + /* Array should include the given value */ + 'search.operator.array-list-includes.name': 'includes', + 'search.operator.array-list-includes.description': + ' includes {{value}}', + /* Array should not include the given value */ + 'search.operator.array-list-not-includes.name': 'does not include', + 'search.operator.array-list-not-includes.description': + ' does not include {{value}}', + /* Array should include the given reference */ + 'search.operator.array-reference-includes.name': 'includes', + 'search.operator.array-reference-includes.description': + ' includes {{value}}', + /* Array should not include the given reference */ + 'search.operator.array-reference-not-includes.name': 'does not include', + 'search.operator.array-reference-not-includes.description': + ' does not include {{value}}', + /* Asset (file) should be the selected asset */ + 'search.operator.asset-file-equal.name': 'is', + 'search.operator.asset-file-equal.description': + ' is {{value}}', + /* Asset (file) should not be the selected asset */ + 'search.operator.asset-file-not-equal.name': 'is not', + 'search.operator.asset-file-not-equal.description': + ' is not {{value}}', + /* Asset (image) should be the selected asset */ + 'search.operator.asset-image-equal.name': 'is', + 'search.operator.asset-image-equal.description': + ' is {{value}}', + /* Asset (image) should not be the selected asset */ + 'search.operator.asset-image-not-equal.name': 'is not', + 'search.operator.asset-image-not-equal.description': + ' is not {{value}}', + /** + * Boolean value should be the given filter value (true/false). + * Context passed is `true` and `false`, allowing for more specific translations: + * - `search.operator.boolean-equal.description_true` + * - `search.operator.boolean-equal.description_false` + */ + 'search.operator.boolean-equal.name': 'is', + 'search.operator.boolean-equal.description': + ' is {{value}}', + /* Date should be after (later than) given filter value */ + 'search.operator.date-after.name': 'after', + 'search.operator.date-after.description': + ' is after {{value}}', + /* Date should be before (earlier than) given filter value */ + 'search.operator.date-before.name': 'before', + 'search.operator.date-before.description': + ' is before {{value}}', + /* Date should be the given filter value */ + 'search.operator.date-equal.name': 'is', + 'search.operator.date-equal.description': + ' is {{value}}', + /* Date should be within the given filter value range (eg "within the last X days") */ + 'search.operator.date-last.name': 'last', + 'search.operator.date-last.description': + ' is in the last {{value}}', + /* Date should not be the given filter value */ + 'search.operator.date-not-equal.name': 'is not', + 'search.operator.date-not-equal.description': + ' is not {{value}}', + /* Date should be within the range of given filter values */ + 'search.operator.date-range.name': 'is between', + 'search.operator.date-range.description': ' is between ', + /* Date and time should be after (later than) given filter value */ + 'search.operator.date-time-after.name': 'after', + 'search.operator.date-time-after.description': + ' is after {{value}}', + /* Date and time should be before (earlier than) given filter value */ + 'search.operator.date-time-before.name': 'before', + 'search.operator.date-time-before.description': + ' is before {{value}}', + /* Date and time should be the given filter value */ + 'search.operator.date-time-equal.name': 'is', + 'search.operator.date-time-equal.description': + ' is {{value}}', + /* Date and time should be within the given filter value range (eg "within the last X days") */ + 'search.operator.date-time-last.name': 'last', + 'search.operator.date-time-last.description': + ' is in the last {{value}}', + /* Date and time should not be the given filter value */ + 'search.operator.date-time-not-equal.name': 'is not', + 'search.operator.date-time-not-equal.description': + ' is not {{value}}', + /* Date and time should be within the range of given filter values */ + 'search.operator.date-time-range.name': 'is between', + 'search.operator.date-time-range.description': + ' is between ', + /* Value should be defined */ + 'search.operator.defined.name': 'not empty', + 'search.operator.defined.description': + ' is not empty', + /* Value should not be defined */ + 'search.operator.not-defined.name': 'empty', + 'search.operator.not-defined.description': + ' is empty', + /* Number should be the given filter value */ + 'search.operator.number-equal.name': 'is', + 'search.operator.number-equal.description': + ' is {{value}}', + /* Number should be greater than given filter value */ + 'search.operator.number-gt.name': 'greater than', + 'search.operator.number-gt.description': + ' > {{value}}', + /* Number should be greater than or the given filter value */ + 'search.operator.number-gte.name': 'greater than or equal to', + 'search.operator.number-gte.description': + ' {{value}}', + /* Number should be less than given filter value */ + 'search.operator.number-lt.name': 'less than', + 'search.operator.number-lt.description': + ' < {{value}}', + /* Number should be less than or the given filter value */ + 'search.operator.number-lte.name': 'less than or equal to', + 'search.operator.number-lte.description': + ' {{value}}', + /* Number should not be the given filter value */ + 'search.operator.number-not-equal.name': 'is not', + 'search.operator.number-not-equal.description': + ' is not {{value}}', + /* Number should be within the range of given filter values */ + 'search.operator.number-range.name': 'is between', + 'search.operator.number-range.description': + ' is between {{from}} → {{to}}', + /* Portable Text should be the given filter value */ + 'search.operator.portable-text-equal.name': 'is', + 'search.operator.portable-text-equal.description': + ' is {{value}}', + /* Portable Text should contain the given filter value */ + 'search.operator.portable-text-contains.name': 'contains', + 'search.operator.portable-text-contains.description': + ' contains {{value}}', + /* Portable Text should not be the given filter value */ + 'search.operator.portable-text-not-equal.name': 'is not', + 'search.operator.portable-text-not-equal.description': + ' is not {{value}}', + /* Portable Text should not contain the given filter value */ + 'search.operator.portable-text-not-contains.name': 'does not contain', + 'search.operator.portable-text-not-contains.description': + ' does not contain {{value}}', + /* Reference should be the given document */ + 'search.operator.reference-equal.name': 'is', + 'search.operator.reference-equal.description': + ' is {{value}}', + /* Reference should not be the given document */ + 'search.operator.reference-not-equal.name': 'is not', + 'search.operator.reference-not-equal.description': + ' is not {{value}}', + /* References the given asset (file) */ + 'search.operator.reference-asset-file.name': 'file', + 'search.operator.reference-asset-file.description': + ' {{value}}', + /* References the given asset (image) */ + 'search.operator.reference-asset-image.name': 'image', + 'search.operator.reference-asset-image.description': + ' {{value}}', + /* References the given document */ + 'search.operator.reference-document.name': 'document', + 'search.operator.reference-document.description': + ' {{value}}', + /* Slug equals the given filter value */ + 'search.operator.slug-equal.name': 'is', + 'search.operator.slug-equal.description': + ' is {{value}}', + /* Slug contains the given value */ + 'search.operator.slug-contains.name': 'contains', + 'search.operator.slug-contains.description': + ' contains {{value}}', + /* Slug does not equal the given filter value */ + 'search.operator.slug-not-equal.name': 'is not', + 'search.operator.slug-not-equal.description': + ' is not {{value}}', + /* Slug does not contain the given value */ + 'search.operator.slug-not-contains.name': 'does not contain', + 'search.operator.slug-not-contains.description': + ' does not contain {{value}}', + /* String equals the given filter value */ + 'search.operator.string-equal.name': 'is', + 'search.operator.string-equal.description': + ' is {{value}}', + /* String equals one of the predefined allowed values */ + 'search.operator.string-list-equal.name': 'is', + 'search.operator.string-list-equal.description': + ' is {{value}}', + /* String does not equal one of the predefined allowed values */ + 'search.operator.string-list-not-equal.name': 'is not', + 'search.operator.string-list-not-equal.description': + ' is not {{value}}', + /* String contains the given filter value */ + 'search.operator.string-contains.name': 'contains', + 'search.operator.string-contains.description': + ' contains {{value}}', + /* String does not equal the given filter value */ + 'search.operator.string-not-equal.name': 'is not', + 'search.operator.string-not-equal.description': + ' is not {{value}}', + /* String does not contain the given filter value */ + 'search.operator.string-not-contains.name': 'does not contain', + 'search.operator.string-not-contains.description': + ' does not contain {{value}}', /** Title label for when no search results are found */ 'search.no-results-title': 'No results found', diff --git a/packages/sanity/src/core/i18n/debug.ts b/packages/sanity/src/core/i18n/debug.ts index 7a0302f3b88..991e6d88aa3 100644 --- a/packages/sanity/src/core/i18n/debug.ts +++ b/packages/sanity/src/core/i18n/debug.ts @@ -8,6 +8,15 @@ import type {TFunction} from 'i18next' */ export const DEBUG_I18N = Boolean(process.env.SANITY_STUDIO_DEBUG_I18N) +/** + * Wrapper function use for debugging. The "reverse" approach is less disruptive to the layout, but + * may be hard to use since it is hard to read labels. The "triangles" approach is easy to spot. + */ +const debugWrapper = + process.env.SANITY_STUDIO_DEBUG_I18N === 'reverse' + ? (str: string) => `‮${str}` + : (str: string) => `◤ ${str} ◢` + /** * If in debug mode, wrap the given `t` function in a function that adds a prefix and suffix to the * translated string. If not, return the original `t` function as-is. @@ -17,5 +26,5 @@ export const DEBUG_I18N = Boolean(process.env.SANITY_STUDIO_DEBUG_I18N) * @internal */ export function maybeWrapT(t: TFunction): TFunction { - return DEBUG_I18N ? (((...args: any) => `◤ ${t(...args)} ◢`) as any as TFunction) : t + return DEBUG_I18N ? (((...args: any) => debugWrapper(t(...args)) as any) as TFunction) : t } diff --git a/packages/sanity/src/core/i18n/hooks/useIntlDateTimeFormat.ts b/packages/sanity/src/core/i18n/hooks/useIntlDateTimeFormat.ts new file mode 100644 index 00000000000..846913ae6af --- /dev/null +++ b/packages/sanity/src/core/i18n/hooks/useIntlDateTimeFormat.ts @@ -0,0 +1,27 @@ +import {intlCache} from '../intlCache' +import {useCurrentLocale} from './useLocale' + +/** + * Options for the `useIntlDateTimeFormat` hook + * + * @public + */ +export type UseIntlDateTimeFormatOptions = Omit< + Intl.DateTimeFormatOptions, + 'fractionalSecondDigits' +> + +/** + * Returns an instance of `Intl.DateTimeFormat` that uses the currently selected locale, + * and enables locale and culture-sensitive date formatting. + * + * @param options - Optional options for the date/time formatter + * @returns Instance of `Intl.DateTimeFormat` + * @public + */ +export function useIntlDateTimeFormat( + options: UseIntlDateTimeFormatOptions = {}, +): Intl.DateTimeFormat { + const currentLocale = useCurrentLocale() + return intlCache.dateTimeFormat(currentLocale, options) +} diff --git a/packages/sanity/src/core/i18n/simpleParser.ts b/packages/sanity/src/core/i18n/simpleParser.ts index 27e300075e8..4fd72ee8461 100644 --- a/packages/sanity/src/core/i18n/simpleParser.ts +++ b/packages/sanity/src/core/i18n/simpleParser.ts @@ -13,7 +13,7 @@ export type TextToken = { } export type Token = OpenTagToken | CloseTagToken | TextToken -const OPEN_TAG_RE = /<(?[^\s\d][^/?><]+)\/?>/ +const OPEN_TAG_RE = /^<(?[^\s\d<][^/?><]+)\/?>/ const CLOSE_TAG_RE = /<\/(?[^>]+)>/ const SELF_CLOSING_RE = /<[^>]+\/>/ const VALID_COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]+$/ @@ -78,7 +78,7 @@ export function simpleParser(input: string): Token[] { text += remainder[0] remainder = remainder.substring(1) } - } else if (openTag && remainder[0] === '<') { + } else if (openTag && remainder[0] === '<' && remainder[1] !== '<') { const match = matchCloseTag(remainder) if (match) { const tagName = match.groups!.tag diff --git a/packages/sanity/src/core/studio/components/navbar/search/__workshop__/CommonDateRangeStory.tsx b/packages/sanity/src/core/studio/components/navbar/search/__workshop__/CommonDateRangeStory.tsx index 4bcfdbcf61a..f6a6c7ff13e 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/__workshop__/CommonDateRangeStory.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/__workshop__/CommonDateRangeStory.tsx @@ -26,7 +26,7 @@ export default function CommonDateRangeStory() { Start: - {value?.dateMin ? value.dateMin : Empty} + {value?.from ? value.from : Empty} @@ -34,7 +34,7 @@ export default function CommonDateRangeStory() { End: - {value?.dateMax ? value.dateMax : Empty} + {value?.to ? value.to : Empty} diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/SortMenu.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/SortMenu.tsx index 0b8b844ce58..eb4f431d3cd 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/SortMenu.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/SortMenu.tsx @@ -14,6 +14,7 @@ import { import isEqual from 'lodash/isEqual' import React, {useCallback, useId, useMemo} from 'react' import styled from 'styled-components' +import {useTranslation} from '../../../../../i18n' import {ORDERINGS} from '../definitions/orderings' import {useSearchState} from '../contexts/search/useSearchState' import type {SearchOrdering} from '../types' @@ -41,6 +42,7 @@ function isSearchDivider(item: SearchDivider | SearchOrdering): item is SearchDi } function CustomMenuItem({ordering}: {ordering: SearchOrdering}) { + const {t} = useTranslation() const { dispatch, state: {ordering: currentOrdering}, @@ -56,7 +58,7 @@ function CustomMenuItem({ordering}: {ordering: SearchOrdering}) { - {ordering.title} + {t(ordering.titleKey)} @@ -64,6 +66,7 @@ function CustomMenuItem({ordering}: {ordering: SearchOrdering}) { } export function SortMenu() { + const {t} = useTranslation() const { state: {ordering}, } = useSearchState() @@ -92,7 +95,7 @@ export function SortMenu() { - {currentMenuItem.title} + {t(currentMenuItem.titleKey)} diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/common/FilterLabel.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/common/FilterLabel.tsx index 2515934c687..dc5e20ee981 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/common/FilterLabel.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/common/FilterLabel.tsx @@ -1,10 +1,13 @@ import {Box, Flex} from '@sanity/ui' -import React from 'react' +import React, {useMemo} from 'react' import styled from 'styled-components' import {TextWithTone} from '../../../../../../components' +import {TranslateComponentMap, Translate} from '../../../../../../i18n/Translate' +import {isRecord} from '../../../../../../util' +import {useTranslation} from '../../../../../../i18n' import {useSearchState} from '../../contexts/search/useSearchState' import {getOperatorDefinition} from '../../definitions/operators' -import type {SearchFilter} from '../../types' +import type {SearchFilter, SearchFilterValues} from '../../types' import {FilterTitle} from './FilterTitle' interface FilterLabelProps { @@ -18,6 +21,7 @@ const CustomBox = styled(Box)<{$flexShrink?: number}>` ` export function FilterLabel({filter, fontSize = 1, showContent = true}: FilterLabelProps) { + const {t} = useTranslation() const { state: {definitions, fullscreen}, } = useSearchState() @@ -25,31 +29,79 @@ export function FilterLabel({filter, fontSize = 1, showContent = true}: FilterLa const operator = getOperatorDefinition(definitions.operators, filter.operatorType) const ButtonValue = operator?.buttonValueComponent + const filterValue = filter.value - return ( - - {/* Title */} - - - - - - {/* Operator */} - {showContent && operator?.buttonLabel && ( - - - {operator.buttonLabel} - - - )} - {/* Value */} - {showContent && ButtonValue && ( - + const components: TranslateComponentMap = useMemo( + () => ({ + Field: () => ( + - + - )} + ), + Operator: ({children}) => + showContent && ( + + + {children} + + + ), + Value: ({children}) => + showContent && ( + + + {ButtonValue ? : children} + + + ), + }), + [filter, fontSize, fullscreen, showContent, ButtonValue, filterValue], + ) + + if (!operator?.descriptionKey) { + console.warn('Missing `descriptionKey` for operator `%s`', filter.operatorType) + } + + if (!showContent || !operator?.descriptionKey) { + const Field = components.Field + return ( + + + + ) + } + + return ( + + ) } + +function getFilterValues(filter: SearchFilter): SearchFilterValues { + const values: SearchFilterValues = {} + if (typeof filter.value === 'number') { + values.count = filter.value + } + if (isStringOrNumber(filter.value)) { + values.value = filter.value + } + if (isRecord(filter.value) && 'from' in filter.value && isStringOrNumber(filter.value.from)) { + values.from = filter.value.from + } + if (isRecord(filter.value) && 'to' in filter.value && isStringOrNumber(filter.value.to)) { + values.to = filter.value.to + } + return values +} + +function isStringOrNumber(value: unknown): value is string | number { + return typeof value === 'string' || typeof value === 'number' +} diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ButtonValue.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ButtonValue.tsx index 72dc2a47b86..1cdde133c1e 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ButtonValue.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ButtonValue.tsx @@ -1,11 +1,10 @@ import type {Reference} from '@sanity/types' -import {format, isValid} from 'date-fns' -import pluralize from 'pluralize-esm' +import {isValid} from 'date-fns' import React from 'react' import {useSchema} from '../../../../../../../hooks' -import type {OperatorNumberRangeValue} from '../../../definitions/operators/common' +import {useUnitFormatter} from '../../../../../../../hooks/useUnitFormatter' +import {useIntlDateTimeFormat} from '../../../../../../../i18n/hooks/useIntlDateTimeFormat' import type { - OperatorDateDirectionValue, OperatorDateEqualValue, OperatorDateLastValue, OperatorDateRangeValue, @@ -24,75 +23,40 @@ export function SearchButtonValueBoolean({value}: OperatorButtonValueComponentPr export function SearchButtonValueDate({ value, }: OperatorButtonValueComponentProps) { + const dateFormat = useIntlDateTimeFormat({ + dateStyle: 'medium', + timeStyle: value.includeTime ? 'short' : undefined, + }) const date = value?.date ? new Date(value.date) : null if (!date || !isValid(date)) { return null } - return <>{format(date, DEFAULT_DATE_FORMAT)} -} - -export function SearchButtonValueDateDirection({ - value, -}: OperatorButtonValueComponentProps) { - const date = value?.date ? new Date(value.date) : null - if (!date || !isValid(date)) { - return null - } - return <>{format(date, DEFAULT_DATE_FORMAT)} + return <>{dateFormat.format(date)} } export function SearchButtonValueDateLast({ value, }: OperatorButtonValueComponentProps) { - return ( - <> - {Math.floor(value?.unitValue ?? 0)} {value.unit} - - ) + const formatUnit = useUnitFormatter() + return <>{formatUnit(Math.floor(value?.unitValue ?? 0), value.unit)} } export function SearchButtonValueDateRange({ value, }: OperatorButtonValueComponentProps) { - const startDate = value?.dateMin ? new Date(value.dateMin) : null - const endDate = value?.dateMax ? new Date(value.dateMax) : null + const dateFormat = useIntlDateTimeFormat({ + dateStyle: 'medium', + timeStyle: value.includeTime ? 'short' : undefined, + }) + const startDate = value?.from ? new Date(value.from) : null + const endDate = value?.to ? new Date(value.to) : null if (!endDate || !startDate || !isValid(endDate) || !isValid(startDate)) { return null } - return ( - <> - {format(startDate, DEFAULT_DATE_FORMAT)} → {format(endDate, DEFAULT_DATE_FORMAT)} - - ) -} - -export function SearchButtonValueNumber({value}: OperatorButtonValueComponentProps) { - return <>{value} -} -export function SearchButtonValueNumberCount({value}: OperatorButtonValueComponentProps) { - return ( - <> - {value} {pluralize('item', value)} - - ) -} - -export function SearchButtonValueNumberRange({ - value, -}: OperatorButtonValueComponentProps) { - return ( - <> - {value.min} → {value.max} - - ) -} - -export function SearchButtonValueNumberCountRange({ - value, -}: OperatorButtonValueComponentProps) { - const {t} = useTranslation() - return <>{t('search.number-items-range', {min: value.min, max: value.max})} + const from = dateFormat.format(startDate) + const to = dateFormat.format(endDate) + return <>{`${from} → ${to}`} } export function SearchButtonValueReference({value}: OperatorButtonValueComponentProps) { @@ -104,9 +68,3 @@ export function SearchButtonValueReference({value}: OperatorButtonValueComponent } return } - -export function SearchButtonValueString({ - value, -}: OperatorButtonValueComponentProps) { - return <>{value} -} diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/OperatorsMenuButton.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/OperatorsMenuButton.tsx index 588fd80c362..29258c30fb4 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/OperatorsMenuButton.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/OperatorsMenuButton.tsx @@ -6,6 +6,7 @@ import {getFilterDefinition} from '../../../definitions/filters' import {getOperatorDefinition, SearchOperatorDefinition} from '../../../definitions/operators' import type {SearchFilter} from '../../../types' import {getFilterKey} from '../../../utils/filterUtils' +import {useTranslation} from '../../../../../../../i18n' interface OperatorsMenuButtonProps { filter: SearchFilter @@ -22,13 +23,14 @@ function CustomMenuItem({ selected: boolean }) { const handleClick = useCallback(() => onClick(operator.type), [onClick, operator.type]) + const {t} = useTranslation() return ( - {operator.label} + {t(operator.nameKey)} {operator?.icon && ( @@ -44,6 +46,7 @@ function CustomMenuItem({ export function OperatorsMenuButton({filter, operator}: OperatorsMenuButtonProps) { const menuButtonId = useId() + const {t} = useTranslation() const {dispatch, state} = useSearchState() const operatorItems = getFilterDefinition(state.definitions.filters, filter.filterName)?.operators @@ -69,7 +72,7 @@ export function OperatorsMenuButton({filter, operator}: OperatorsMenuButtonProps