From c4d8654d3a78180705a29ef498d6822ab982b052 Mon Sep 17 00:00:00 2001 From: arpandhakal Date: Tue, 28 Jul 2026 17:10:58 +0545 Subject: [PATCH] feat(OUT-4014): capitalize actionLabel verb at render time App builders register `actionLabel.verb` with no casing rule enforced, so it arrives in whatever shape the app author typed ("review", "reView", "REVIEW"). Normalize it to sentence case in `toDynamicActionDefinition`, the single funnel for Studio app action labels into the render path. Nouns pass through verbatim since they render mid-phrase. Pre-existing built-in actions keep their hardcoded verbs. Co-Authored-By: Claude Opus 5 (1M context) --- .../installed-apps/lib/dynamic-action.ts | 11 +++- tests/unit/dynamic-action.test.ts | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/unit/dynamic-action.test.ts diff --git a/src/features/installed-apps/lib/dynamic-action.ts b/src/features/installed-apps/lib/dynamic-action.ts index 76e11a80..3a7df327 100644 --- a/src/features/installed-apps/lib/dynamic-action.ts +++ b/src/features/installed-apps/lib/dynamic-action.ts @@ -6,10 +6,19 @@ import { isIconType } from '@installed-apps/lib/icon-names' // Stands in when an install's `icon` is absent or not a design-system IconType. const FALLBACK_ICON: IconType = 'CustomApps' +// App builders register `actionLabel.verb` with no casing rule enforced, so it arrives in +// whatever shape the app author typed ("review", "reView", "REVIEW"). The verb leads the +// rendered row, so normalize it to sentence case: first letter up, remainder down. +export const capitalizeVerb = (verb: string) => { + const trimmed = verb.trim() + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase() +} + // A "Your Actions" row synthesized at runtime from an actionable Studio app install. // Mirrors ActionDefinition's presentational fields so ActionItem renders it identically, // but is identified by its install/app rather than a built-in ActionKey. export type DynamicActionDefinition = { + /** Sentence-cased verb, e.g. "Review". */ verb: string /** Plural noun, e.g. "documents". */ label: string @@ -26,7 +35,7 @@ export type RenderableAction = ActionDefinition | DynamicActionDefinition export const isDynamicAction = (action: RenderableAction): action is DynamicActionDefinition => 'installId' in action export const toDynamicActionDefinition = (install: ActionableInstallDto): DynamicActionDefinition => ({ - verb: install.actionLabel.verb, + verb: capitalizeVerb(install.actionLabel.verb), label: install.actionLabel.pluralNoun, singularLabel: install.actionLabel.singularNoun, icon: isIconType(install.icon) ? install.icon : FALLBACK_ICON, diff --git a/tests/unit/dynamic-action.test.ts b/tests/unit/dynamic-action.test.ts new file mode 100644 index 00000000..a1a967ba --- /dev/null +++ b/tests/unit/dynamic-action.test.ts @@ -0,0 +1,51 @@ +import type { ActionableInstallDto } from '@installed-apps/installed-apps.dto' +import { capitalizeVerb, toDynamicActionDefinition } from '@installed-apps/lib/dynamic-action' +import { describe, expect, it } from 'vitest' + +const createActionableInstall = (verb: string): ActionableInstallDto => ({ + installId: 'install_1', + appId: 'app_1', + displayName: 'Docs', + icon: 'CustomApps', + actionLabel: { verb, singularNoun: 'document', pluralNoun: 'documents' }, +}) + +describe('capitalizeVerb', () => { + it('capitalizes a lowercase verb', () => { + expect(capitalizeVerb('review')).toBe('Review') + }) + + it('normalizes inconsistent inner casing', () => { + expect(capitalizeVerb('reView')).toBe('Review') + }) + + it('normalizes an all-caps verb', () => { + expect(capitalizeVerb('REVIEW')).toBe('Review') + }) + + it('leaves an already-capitalized verb unchanged', () => { + expect(capitalizeVerb('Review')).toBe('Review') + }) + + it('capitalizes only the first word of a multi-word verb', () => { + expect(capitalizeVerb('sign off')).toBe('Sign off') + }) + + it('trims surrounding whitespace', () => { + expect(capitalizeVerb(' review ')).toBe('Review') + }) + + it('returns an empty string for an empty verb', () => { + expect(capitalizeVerb(' ')).toBe('') + }) +}) + +describe('toDynamicActionDefinition', () => { + it('capitalizes the verb and passes the nouns through verbatim', () => { + const action = toDynamicActionDefinition(createActionableInstall('reView')) + + expect(action.verb).toBe('Review') + expect(action.label).toBe('documents') + expect(action.singularLabel).toBe('document') + }) +})