From 324058932387cdeedbefd47d741704d9a6cb828c Mon Sep 17 00:00:00 2001 From: ashk1996 <125904756+ashk1996@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:33:38 +0100 Subject: [PATCH 1/2] feat(ci): improving release process using release-please (#1176) --- .github/workflows/release.yml | 29 ++++++++++++ docs/DEPLOYMENT.md | 89 ++++++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..6cd272908 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Release Please Action + uses: googleapis/release-please-action@v4 + id: release + with: + release-type: node + package-name: '@ashk1996/boiler' + token: ${{ secrets.GITHUB_TOKEN }} + target-branch: main + branch: main diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7168b6e52..605b92839 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,14 +1,22 @@ # Deployment + This chapter should provide the user with all needed information around the deployment of your project. -Currently, the B01LER project is getting deployed to [render.com](https://render.com) periodically. In the near future, we intend on implementing a more formal deployment schedule so that the latest version of the project will be available to view and interact with. +Currently, the B01LER project is getting deployed to [render.com](https://render.com) periodically. In the near future, +we intend on implementing a more formal deployment schedule so that the latest version of the project will be available +to view and interact with. -The project can be viewed [here](https://b01ler.onrender.com/). This link allows you to experiment with the project and learn about how you can use components via Storybook. +The project can be viewed [here](https://b01ler.onrender.com/). This link allows you to experiment with the project and +learn about how you can use components via Storybook. -We also deploy our JS Example app to [Render](https://b01ler.onrender.com/js-example-app). This application shows you how our components look when implemented in a vanilla Javascript application. +We also deploy our JS Example app to [Render](https://b01ler.onrender.com/js-example-app). This application shows you +how our components look when implemented in a vanilla Javascript application. ## Content + - [Tooling](#tooling) +- [Release Please](#release-please) +- [Conventional Commit Messages](#conventional-commit-messages) - [How to deploy](#how-to-deploy) - [Versioning](#versioning) - [Release Management](#release-management) @@ -16,22 +24,81 @@ We also deploy our JS Example app to [Render](https://b01ler.onrender.com/js-exa - [Support](#support) ## Tooling -Please explain what kind of tools you use for your deployment, why you have choosen them and how to work with them. +This project uses release-please and conventional commit messages for automated release creation and deploys the package +to the npm registry. + +We use the [Git-Flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) branching model: + +```mermaid + gitGraph + commit id: "a" + commit id: "b" + branch develop + checkout develop + checkout develop + branch feature1 + checkout feature1 + commit id: "c" + checkout develop + merge feature1 + branch feature2 + checkout feature2 + commit id: "d" + checkout develop + merge feature2 + checkout main + merge develop + branch "release" + checkout release + commit id: "1.0.0" tag: "release" + checkout main + merge release + checkout develop + merge main +``` + +## Release Please + +Release Please automates CHANGELOG generation, the creation of GitHub releases, and version bumps for your projects. +Release Please does so by parsing the git history, looking for Conventional Commit messages, and creating release PRs. + +The tool runs on every update on the `main` branch and creates a release PR which needs to be manually be merged to +create the release. + +The updated `main` branch then needs to be merged back, please use rebase, into the `develop` branch. + +## Conventional Commit Messages + +The commits must be compliant with with the +[Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). The commit header should not +exceed a maximum character count of 140. The scope is allowed to be one of the following options: 'all', 'ui-library', +'icons', 'figma-design-tokens', 'tokens', or 'storybook'. ## How to deploy -In this section you should discribe the deployment process for your project. Think about how developers who are unfamiliar with the project can deploy it to the respective enviroments. +One needs to create a new PR from `develop` to `main`. Once the PR is approved and merged the GitHub workflow `release` +will be started. -## Versioning -Please describe briefly how you manage the versioning of your project. +The `release` workflow will first run the tests and after they were successful a new release branch is created together +with a PR from the release branch into `main`. + +The newly created release PR needs to be manually merged into main to finish the package creation and release +publishing. +After the release PR is merged the `develop` branch also needs to be updated by rebasing it onto the `main` branch. -## Release Management -If you have a release management setup in place, please describe the process and who to contact if support is needed. +## Versioning + +Please see [conventional commit messages](#conventional-commit-messages) ## Deployment Schedule -Please write down your deployment schedule. Consider using screenshots or graphs for better visualization. + +There is no deployment schedule. A new release is created whenever we have multiple new small features or a new big +feature that we want to release. ## Support -If support is needed while deploying BO1LER, please refer to our [README feedback section](/README.md#tipping_hand_person-help--feedback) where we list the many ways that we can be reached to support you. + +If support is needed while deploying BO1LER, please refer to our +[README feedback section](/README.md#tipping_hand_person-help--feedback) where we list the many ways that we can be +reached to support you. From 21856a3203a41695c324c943ba56c3cb9ddc8573 Mon Sep 17 00:00:00 2001 From: ashk1996 <125904756+ashk1996@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:36:02 +0100 Subject: [PATCH 2/2] feat(ui-library): introduce property sanitizer using preact and controller (#1175) --- .../src/components/button-text/index.ts | 56 ++++++++---- .../src/components/input-field-text/index.ts | 67 ++++++++++---- .../src/components/textarea/index.ts | 90 +++++++++++++------ .../src/utils/lit/sanitization-controller.ts | 48 ++++++++++ packages/ui-library/src/utils/lit/sanitize.ts | 51 +++++++++++ packages/ui-library/src/utils/lit/signals.ts | 11 ++- 6 files changed, 259 insertions(+), 64 deletions(-) create mode 100644 packages/ui-library/src/utils/lit/sanitization-controller.ts create mode 100644 packages/ui-library/src/utils/lit/sanitize.ts diff --git a/packages/ui-library/src/components/button-text/index.ts b/packages/ui-library/src/components/button-text/index.ts index e19636687..17d602e50 100644 --- a/packages/ui-library/src/components/button-text/index.ts +++ b/packages/ui-library/src/components/button-text/index.ts @@ -30,6 +30,8 @@ import { } from '../../globals/events.js'; import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { makeSanitizer } from '../../utils/lit/sanitize.js'; +import { SanitizationController } from '../../utils/lit/sanitization-controller.js'; export type BlrButtonTextEventHandlers = { blrFocus?: (event: BlrFocusEvent) => void; @@ -37,23 +39,46 @@ export type BlrButtonTextEventHandlers = { blrClick?: (event: BlrClickEvent) => void; }; +const propertySanitizer = makeSanitizer((unsanitized: BlrButtonTextType) => ({ + iconPosition: unsanitized.iconPosition ?? 'leading', + sizeVariant: unsanitized.sizeVariant ?? 'md', + buttonDisplay: unsanitized.buttonDisplay ?? 'inline-block', +})); + /** * @fires blrFocus Button received focus * @fires blrBlur Button lost focus * @fires blrClick Button was clicked */ export class BlrButtonText extends LitElementCustom { + private sanitizedController: SanitizationController< + BlrButtonTextType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >; + + constructor() { + super(); + this.sanitizedController = new SanitizationController< + BlrButtonTextType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >({ + host: this, + sanitize: propertySanitizer, + }); + } static styles = [styleCustom, staticActionStyles]; @property() accessor label = 'Button Label'; - @property() accessor icon: SizelessIconType | undefined = undefined; - @property() accessor iconPosition: IconPositionVariant | undefined = 'leading'; + @property() accessor icon: SizelessIconType | undefined; + @property() accessor iconPosition: IconPositionVariant | undefined; @property({ type: Boolean }) accessor loading!: boolean; @property({ type: Boolean }) accessor disabled!: boolean; @property() accessor buttonTextId: string | undefined; @property() accessor variant: ActionVariantType = 'primary'; - @property() accessor sizeVariant: ActionSizesType | undefined = 'md'; - @property() accessor buttonDisplay: DisplayType | undefined = 'inline-block'; + @property() accessor sizeVariant: ActionSizesType | undefined; + @property() accessor buttonDisplay: DisplayType | undefined; @property() accessor theme: ThemeType = 'Light_value'; @@ -80,27 +105,28 @@ export class BlrButtonText extends LitElementCustom { }; protected render() { - if (this.sizeVariant && this.buttonDisplay) { + const sanitized = this.sanitizedController.values; + if (sanitized.sizeVariant && this.buttonDisplay) { const classes = classMap({ 'blr-semantic-action': true, 'blr-button-text': true, [this.variant]: this.variant, - [this.sizeVariant]: this.sizeVariant, + [sanitized.sizeVariant]: sanitized.sizeVariant, 'disabled': this.disabled, 'loading': this.loading, - [this.buttonDisplay]: this.buttonDisplay, + [sanitized.buttonDisplay]: sanitized.buttonDisplay, [this.theme]: this.theme, }); const iconClasses = classMap({ 'icon': true, - 'leading-icon-class': this.iconPosition === 'leading', - 'trailing-icon-class': this.iconPosition === 'trailing', + 'leading-icon-class': sanitized.iconPosition === 'leading', + 'trailing-icon-class': sanitized.iconPosition === 'trailing', }); const flexContainerClasses = classMap({ 'flex-container': true, - [this.sizeVariant]: this.sizeVariant, + [sanitized.sizeVariant]: sanitized.sizeVariant, [this.theme]: this.theme, }); @@ -118,7 +144,7 @@ export class BlrButtonText extends LitElementCustom { 'buttons', 'loader', 'sizevariant', - this.sizeVariant, + sanitized.sizeVariant, ]).toLowerCase() as FormSizesType; const iconSizeVariant = getComponentConfigToken([ @@ -126,11 +152,11 @@ export class BlrButtonText extends LitElementCustom { 'buttontext', 'icon', 'sizevariant', - this.sizeVariant, - ]) as SizesType; + sanitized.sizeVariant, + ]).toLowerCase() as SizesType; const labelAndIconGroup = html`
- ${this.icon && this.iconPosition === 'leading' + ${this.icon && sanitized.iconPosition === 'leading' ? BlrIconRenderFunction( { icon: calculateIconName(this.icon, iconSizeVariant), @@ -144,7 +170,7 @@ export class BlrButtonText extends LitElementCustom { ) : nothing} ${this.label} - ${this.icon && this.iconPosition === 'trailing' + ${this.icon && sanitized.iconPosition === 'trailing' ? BlrIconRenderFunction( { icon: calculateIconName(this.icon, iconSizeVariant), diff --git a/packages/ui-library/src/components/input-field-text/index.ts b/packages/ui-library/src/components/input-field-text/index.ts index 60be3e8c2..9bc40e5ce 100644 --- a/packages/ui-library/src/components/input-field-text/index.ts +++ b/packages/ui-library/src/components/input-field-text/index.ts @@ -26,6 +26,8 @@ import { import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; import { BlrIconEventHandlers } from '../icon/index.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { makeSanitizer } from '../../utils/lit/sanitize.js'; +import { SanitizationController } from '../../utils/lit/sanitization-controller.js'; export type BlrInputFieldTextEventHandlers = { blrFocus?: (event: BlrFocusEvent) => void; @@ -34,6 +36,12 @@ export type BlrInputFieldTextEventHandlers = { blrSelect?: (event: BlrSelectEvent) => void; }; +const propertySanitizer = makeSanitizer((unsanitized: BlrInputFieldTextType) => ({ + type: unsanitized.type ?? 'text', + sizeVariant: unsanitized.sizeVariant ?? 'md', + theme: unsanitized.theme ?? 'Light_value', +})); + /** * @fires blrFocus InputFieldText received focus * @fires blrBlur InputFieldText lost focus @@ -41,13 +49,30 @@ export type BlrInputFieldTextEventHandlers = { * @fires blrSelect Text in InputFieldText got selected */ export class BlrInputFieldText extends LitElementCustom { + private sanitizedController: SanitizationController< + BlrInputFieldTextType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >; + + constructor() { + super(); + this.sanitizedController = new SanitizationController< + BlrInputFieldTextType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >({ + host: this, + sanitize: propertySanitizer, + }); + } static styles = [styleCustom]; @query('input') protected accessor _inputFieldTextNode!: HTMLInputElement; @property() accessor inputFieldTextId!: string; - @property() accessor type: InputTypes = 'text'; + @property() accessor type: InputTypes | undefined; @property() accessor arialabel!: string; @property({ type: Boolean }) accessor hasLabel!: boolean; @property() accessor label!: string; @@ -56,7 +81,7 @@ export class BlrInputFieldText extends LitElementCustom { @property() accessor placeholder: string | undefined; @property({ type: Boolean }) accessor disabled: boolean | undefined; @property({ type: Boolean }) accessor readonly: boolean | undefined; - @property() accessor sizeVariant: FormSizesType | undefined = 'md'; + @property() accessor sizeVariant: FormSizesType | undefined; @property({ type: Boolean }) accessor required: boolean | undefined; @property({ type: Number }) accessor maxLength: number | undefined; @property() accessor pattern: string | undefined; @@ -69,9 +94,9 @@ export class BlrInputFieldText extends LitElementCustom { @property() accessor errorMessageIcon: SizelessIconType | undefined; @property() accessor name!: string; - @property() accessor theme: ThemeType = 'Light_value'; + @property() accessor theme: ThemeType | undefined; - @state() protected accessor currentType: InputTypes = this.type; + @state() protected accessor currentType: InputTypes | undefined; @state() protected accessor isFocused = false; protected willUpdate(_changedProperties: PropertyValueMap | Map): void { @@ -155,7 +180,7 @@ export class BlrInputFieldText extends LitElementCustom { 'icon-input': true, [this.sizeVariant!]: this.sizeVariant!, 'no-pointer-events': Boolean(this.disabled || this.type !== 'password'), - [this.theme]: this.theme, + [this.theme!]: this.theme!, }); const iconName: SizelessIconType | undefined = @@ -174,19 +199,20 @@ export class BlrInputFieldText extends LitElementCustom { }, ); } - protected render() { - if (this.sizeVariant) { + const sanitized = this.sanitizedController.values; + + if (sanitized.sizeVariant) { const classes = classMap({ 'blr-input-field-text': true, - [this.sizeVariant]: this.sizeVariant, - [this.theme]: this.theme, + [sanitized.sizeVariant]: sanitized.sizeVariant, + [sanitized.theme]: this.theme, }); const inputClasses = classMap({ 'error-input': this.hasError || false, 'disabled': this.disabled || false, - [this.sizeVariant]: this.sizeVariant, + [sanitized.sizeVariant]: sanitized.sizeVariant, }); const inputContainerClasses = classMap({ @@ -194,16 +220,16 @@ export class BlrInputFieldText extends LitElementCustom { 'error-input': this.hasError || false, 'disabled': this.disabled || false, 'readonly': this.readonly ? true : false, - [this.sizeVariant]: this.sizeVariant, - [this.theme]: this.theme, + [sanitized.sizeVariant]: sanitized.sizeVariant, + [sanitized.theme]: sanitized.theme, }); const captionContent = html` ${this.hasHint && (this.hintMessage || this.hintMessageIcon) ? BlrFormCaptionRenderFunction({ variant: 'hint', - theme: this.theme, - sizeVariant: this.sizeVariant, + theme: sanitized.theme, + sizeVariant: sanitized.sizeVariant, message: this.hintMessage, icon: this.hintMessageIcon, }) @@ -211,7 +237,7 @@ export class BlrInputFieldText extends LitElementCustom { ${this.hasError && (this.errorMessage || this.errorMessageIcon) ? BlrFormCaptionRenderFunction({ variant: 'error', - theme: this.theme, + theme: sanitized.theme, sizeVariant: this.sizeVariant, message: this.errorMessage, icon: this.errorMessageIcon, @@ -226,10 +252,10 @@ export class BlrInputFieldText extends LitElementCustom {
${BlrFormLabelRenderFunction({ label: this.label, - sizeVariant: this.sizeVariant, + sizeVariant: sanitized.sizeVariant, labelAppendix: this.labelAppendix, forValue: this.inputFieldTextId, - theme: this.theme, + theme: sanitized.theme, hasError: Boolean(this.hasError), })}
@@ -242,7 +268,7 @@ export class BlrInputFieldText extends LitElementCustom { id=${this.inputFieldTextId} name="${ifDefined(this.name)}" aria-label=${this.arialabel} - type="${this.currentType}" + type="${sanitized.type === 'text' ? this.currentType : sanitized.type}" .value="${this.value}" placeholder="${ifDefined(this.placeholder)}" ?disabled="${this.disabled}" @@ -260,7 +286,10 @@ export class BlrInputFieldText extends LitElementCustom { ${this.renderInputIcon()}
${(this.hasHint && this.hintMessage) || (this.hasError && this.errorMessage) - ? BlrFormCaptionGroupRenderFunction({ theme: this.theme, sizeVariant: this.sizeVariant }, captionContent) + ? BlrFormCaptionGroupRenderFunction( + { theme: sanitized.theme, sizeVariant: sanitized.sizeVariant }, + captionContent, + ) : nothing} `; diff --git a/packages/ui-library/src/components/textarea/index.ts b/packages/ui-library/src/components/textarea/index.ts index f9b22c7fb..a3ab78c3f 100644 --- a/packages/ui-library/src/components/textarea/index.ts +++ b/packages/ui-library/src/components/textarea/index.ts @@ -24,6 +24,8 @@ import { createBlrTextValueChangeEvent, } from '../../globals/events.js'; import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; +import { makeSanitizer } from '../../utils/lit/sanitize.js'; +import { SanitizationController } from '../../utils/lit/sanitization-controller.js'; export type BlrTextareaEventHandlers = { blrFocus?: (event: BlrFocusEvent) => void; @@ -32,6 +34,16 @@ export type BlrTextareaEventHandlers = { blrSelect?: (event: BlrSelectEvent) => void; }; +const propertySanitizer = makeSanitizer((unsanitized: BlrTextareaType) => ({ + sizeVariant: unsanitized.sizeVariant ?? 'md', + warningLimitType: unsanitized.warningLimitType ?? 'warningLimitInt', + warningLimitInt: unsanitized.warningLimitInt ?? 105, + warningLimitPer: unsanitized.warningLimitPer ?? 75, + resize: unsanitized.resize ?? 'none', + hasHint: unsanitized.hasHint ?? true, + theme: unsanitized.theme ?? 'Light_value', +})); + /** * @fires blrFocus Textarea received focus * @fires blrBlur Textarea lost focus @@ -39,6 +51,24 @@ export type BlrTextareaEventHandlers = { * @fires blrSelect Text in Textarea got selected */ export class BlrTextarea extends LitElementCustom { + private sanitizedController: SanitizationController< + BlrTextareaType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >; + + constructor() { + super(); + this.sanitizedController = new SanitizationController< + BlrTextareaType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >({ + host: this, + sanitize: propertySanitizer, + }); + } + static styles = [staticFormStyles, staticStyles]; @query('textarea') @@ -53,13 +83,13 @@ export class BlrTextarea extends LitElementCustom { @property({ type: Boolean }) accessor disabled: boolean | undefined; @property({ type: Boolean }) accessor readonly: boolean | undefined; @property({ type: Boolean }) accessor hasLabel: boolean | undefined; - @property() accessor sizeVariant: FormSizesType | undefined = 'md'; + @property() accessor sizeVariant: FormSizesType | undefined; @property({ type: Boolean }) accessor required: boolean | undefined; @property({ type: Number }) accessor maxLength: number | undefined; @property({ type: Number }) accessor minLength: number | undefined; - @property() accessor warningLimitType: WarningLimits = 'warningLimitInt'; - @property({ type: Number }) accessor warningLimitInt = 105; - @property({ type: Number }) accessor warningLimitPer = 75; + @property() accessor warningLimitType: WarningLimits | undefined; + @property({ type: Number }) accessor warningLimitInt: number | undefined; + @property({ type: Number }) accessor warningLimitPer: number | undefined; @property() accessor pattern: string | undefined; @property({ type: Boolean }) accessor hasError: boolean | undefined; @property() accessor errorMessage: string | undefined; @@ -69,12 +99,12 @@ export class BlrTextarea extends LitElementCustom { @property() accessor hintMessage: string | undefined; @property({ type: Boolean }) accessor hasCounter: boolean | undefined; @property() accessor hintMessageIcon: SizelessIconType | undefined; - @property() accessor resize: ResizeType = 'none'; + @property() accessor resize: ResizeType | undefined; @property({ type: Number }) accessor rows: number | undefined; @property({ type: Number }) accessor cols: number | undefined; @property() accessor name: string | undefined; @property() accessor textAreaDisplay: DisplayType | undefined = 'block'; - @property() accessor theme: ThemeType = 'Light_value'; + @property() accessor theme: ThemeType | undefined; @state() protected accessor count = 0; @query('textarea') protected accessor textareaElement: HTMLTextAreaElement | null = null; @@ -113,11 +143,15 @@ export class BlrTextarea extends LitElementCustom { protected determinateCounterVariant(): CounterVariantType { let counterVariant: CounterVariantType = 'neutral'; - if (this.maxLength) { - if (this.warningLimitType === 'warningLimitPer' && this.count >= (this.maxLength / 100) * this.warningLimitPer) { - counterVariant = 'warn'; - } else if (this.warningLimitType === 'warningLimitInt' && this.count >= this.warningLimitInt) { - counterVariant = 'warn'; + if (this.maxLength !== undefined) { + if (this.warningLimitType === 'warningLimitPer' && this.warningLimitPer !== undefined) { + if (this.count >= (this.maxLength / 100) * this.warningLimitPer) { + counterVariant = 'warn'; + } + } else if (this.warningLimitType === 'warningLimitInt' && this.warningLimitInt !== undefined) { + if (this.count >= this.warningLimitInt) { + counterVariant = 'warn'; + } } if (this.count >= this.maxLength) { @@ -153,33 +187,35 @@ export class BlrTextarea extends LitElementCustom { }; protected render() { - if (this.sizeVariant && this.textAreaDisplay) { + const sanitized = this.sanitizedController.values; + + if (sanitized.sizeVariant && this.textAreaDisplay) { const classes = classMap({ 'blr-textarea': true, - [this.theme]: true, + [sanitized.theme]: true, 'error': this.hasError || false, - [this.sizeVariant]: this.sizeVariant, + [sanitized.sizeVariant]: sanitized.sizeVariant, [this.textAreaDisplay]: this.textAreaDisplay, }); const textareaClasses = classMap({ 'textarea-input-control': true, - [this.theme]: true, + [sanitized.theme]: true, 'error': this.hasError || false, - [this.resize]: this.resize, - [this.sizeVariant]: this.sizeVariant, + [sanitized.resize]: sanitized.resize, + [sanitized.sizeVariant]: sanitized.sizeVariant, [this.textAreaDisplay]: this.textAreaDisplay, 'disabled': this.disabled || false, }); const textareaInfoContainer = classMap({ 'blr-textarea-info-container': true, - [this.theme]: this.theme, + [sanitized.theme]: sanitized.theme, 'hint': this.hasHint || false, 'error': this.hasError || false, 'error-message': this.errorMessage || false, 'hint-message': this.hintMessage || false, - [this.sizeVariant]: this.sizeVariant, + [sanitized.sizeVariant]: sanitized.sizeVariant, }); const counterVariant = this.determinateCounterVariant(); @@ -188,7 +224,7 @@ export class BlrTextarea extends LitElementCustom { ${this.hasHint && (this.hintMessage || this.hintMessageIcon) ? BlrFormCaptionRenderFunction({ variant: 'hint', - theme: this.theme, + theme: sanitized.theme, sizeVariant: this.sizeVariant, message: this.hintMessage, icon: this.hintMessageIcon, @@ -197,8 +233,8 @@ export class BlrTextarea extends LitElementCustom { ${this.hasError && (this.errorMessage || this.errorMessageIcon) ? BlrFormCaptionRenderFunction({ variant: 'error', - theme: this.theme, - sizeVariant: this.sizeVariant, + theme: sanitized.theme, + sizeVariant: sanitized.sizeVariant, message: this.errorMessage, icon: this.errorMessageIcon, }) @@ -211,10 +247,10 @@ export class BlrTextarea extends LitElementCustom { ? html`
${BlrFormLabelRenderFunction({ label: this.label, - sizeVariant: this.sizeVariant, + sizeVariant: sanitized.sizeVariant, labelAppendix: this.labelAppendix, forValue: this.textAreaId, - theme: this.theme, + theme: sanitized.theme, hasError: Boolean(this.hasError), })}
` @@ -243,7 +279,7 @@ export class BlrTextarea extends LitElementCustom {
${(this.hasHint && this.hintMessage) || (this.hasError && this.errorMessage) ? BlrFormCaptionGroupRenderFunction( - { sizeVariant: this.sizeVariant, theme: this.theme }, + { sizeVariant: sanitized.sizeVariant, theme: sanitized.theme }, getCaptionContent(), ) : nothing} @@ -252,8 +288,8 @@ export class BlrTextarea extends LitElementCustom { variant: counterVariant, value: this.count, maxValue: this.maxLength || 0, - sizeVariant: this.sizeVariant, - theme: this.theme, + sizeVariant: sanitized.sizeVariant, + theme: sanitized.theme, }) : nothing}
diff --git a/packages/ui-library/src/utils/lit/sanitization-controller.ts b/packages/ui-library/src/utils/lit/sanitization-controller.ts new file mode 100644 index 000000000..a42c55eb2 --- /dev/null +++ b/packages/ui-library/src/utils/lit/sanitization-controller.ts @@ -0,0 +1,48 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { SignalHub, registerSignal } from '../../utils/lit/signals.js'; +import { LitElementCustom } from '../../utils/lit/element.js'; +import { SanitizeFunction, SanitizeFunctionResult } from './sanitize.js'; +import { Signal } from '@lit-labs/preact-signals'; +export class SanitizationController> + implements ReactiveController +{ + private _host: ReactiveControllerHost & LitElementCustom; + private _sanitize: SanitizeFunction; + private _sanitizedSignal: Signal; + + constructor({ + host, + sanitize, + }: { + host: ReactiveControllerHost & LitElementCustom; + sanitize: SanitizeFunction; + }) { + this._host = host; + this._sanitize = sanitize; + + // Initialize signal with the result of initial sanitization + this._sanitizedSignal = new Signal(this._sanitize(this._host as TTarget)); + + registerSignal( + this._host.signals as SignalHub<{ sanitizedValues: TResult }>, + 'sanitizedValues', + this._sanitizedSignal, + ); + + this._host.addController(this); + } + + private _sanitizeValues(): void { + const sanitizedValues = this._sanitize(this._host as TTarget); + this._sanitizedSignal.value = sanitizedValues; + } + + public get values(): Readonly { + return this._sanitizedSignal.value; + } + + hostConnected(): void { + // Perform sanitization when the host connects + this._sanitizeValues(); + } +} diff --git a/packages/ui-library/src/utils/lit/sanitize.ts b/packages/ui-library/src/utils/lit/sanitize.ts new file mode 100644 index 000000000..ab8925333 --- /dev/null +++ b/packages/ui-library/src/utils/lit/sanitize.ts @@ -0,0 +1,51 @@ +/** + * ## Usage + * ```typescript + * type User = { + * name: string + * addresses?: Address[] + * status?: 'offline' | 'online' + * } + * + * const sanitizeUser = makeSanitizer((target: User) => ({ + * status: typeof target.status === 'string' ? target.status : 'offline', + * addresses: Array.isArray(target.addresses) ? target.addresses : [], + * })); + * + * sanitizeUser({ + * name: 'John' + * }) + * // ^ Yields + * // { + * // addresses: [], + * // status: 'offline' + * // } + * + * sanitizeUser({ + * name: 'John', + * addresses: [{ street: 'Rogue Street, 13' }], + * status: 'online' + * }) + * // ^ Yields + * // { + * // addresses: [{ street: 'Rogue Street, 13' }], + * // status: 'online' + * // } + * + * ``` + */ +export function makeSanitizer>( + sanitize: SanitizeFunction, +): (unsanitized: TTarget) => TSanitizerReturn { + return sanitize; +} + +export type Sanitized = { + readonly [P in keyof TTarget]: Exclude; +}; + +export type SanitizeFunctionResult = Partial>; + +export type SanitizeFunction> = ( + unsanitized: TTarget, +) => Sanitized; diff --git a/packages/ui-library/src/utils/lit/signals.ts b/packages/ui-library/src/utils/lit/signals.ts index 00ac0b17e..529e0589c 100644 --- a/packages/ui-library/src/utils/lit/signals.ts +++ b/packages/ui-library/src/utils/lit/signals.ts @@ -1,13 +1,18 @@ -import { Signal, ReadonlySignal } from '@lit-labs/preact-signals'; +import { Signal } from '@lit-labs/preact-signals'; import { LitElementCustom } from './element.js'; -export function registerSignal(hub: SignalHub, key: keyof TProps, signal: Signal) { +export function registerSignal( + hub: SignalHub, + key: keyof TProps, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signal: Signal, +) { Object.defineProperty(hub, key, { value: signal }); } export type SignalHub = Omit< { - readonly [P in keyof TProps]-?: ReadonlySignal; + readonly [P in keyof TProps]-?: Signal; }, keyof LitElementCustom >;