diff --git a/.eslintignore b/.eslintignore index fac21f80da9..129d53100bd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,6 +5,9 @@ packages/base/types # build results for charts package packages/charts/dist +# build results for cli package +packages/cli/dist + # build results for main package packages/main/dist packages/main/ssr diff --git a/.github/workflows/test-web-components.yml b/.github/workflows/test-web-components.yml deleted file mode 100644 index 0865cf29075..00000000000 --- a/.github/workflows/test-web-components.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Test (Web Components) - -on: - push: - branches: - - 'main' - paths: - - 'packages/main/src/webComponents/**/*' - pull_request: - paths: - - 'packages/main/src/webComponents/**/*' - -jobs: - cypress-test: - name: 'Cypress' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Cypress run - uses: cypress-io/github-action@v5 - with: - build: yarn build - browser: chrome - component: true - spec: | - packages/main/src/webComponents - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: web-components-coverage - path: temp/cypress-coverage diff --git a/.storybook/components/DocsHeader.tsx b/.storybook/components/DocsHeader.tsx index 8cb76017a8d..5d02573ce73 100644 --- a/.storybook/components/DocsHeader.tsx +++ b/.storybook/components/DocsHeader.tsx @@ -66,8 +66,8 @@ export const DocsHeader = ({ since }) => { - + ); }; diff --git a/package.json b/package.json index b2b3b5e4d3a..4d8aeb7f1ba 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "prettier:all": "prettier --write --config ./prettier.config.cjs \"packages/**/*.{js,jsx,ts,tsx,mdx,json,md}\"", "lint": "eslint packages --ext .ts,.tsx", "lerna:version-dryrun": "lerna version --conventional-graduate --no-git-tag-version --no-push", - "create-webcomponents-wrapper": "node --experimental-json-modules ./packages/main/scripts/create-web-components-wrapper.mjs && node --experimental-json-modules ./scripts/generate-theming-parameters.js", + "wrappers:main": "WITH_WEB_COMPONENT_IMPORT_PATH='../../internal/withWebComponent.js' INTERFACES_IMPORT_PATH='../../types/index.js' node packages/cli/dist/bin/index.js create-wrappers --packageName @ui5/webcomponents --out ./packages/main/src/webComponents --additionalComponentNote 'This is a UI5 Web Component! [Repository](https://github.com/SAP/ui5-webcomponents) | [Documentation](https://sap.github.io/ui5-webcomponents/playground/)'", + "wrappers:fiori": "WITH_WEB_COMPONENT_IMPORT_PATH='../../internal/withWebComponent.js' INTERFACES_IMPORT_PATH='../../types/index.js' node packages/cli/dist/bin/index.js create-wrappers --packageName @ui5/webcomponents-fiori --out ./packages/main/src/webComponents --additionalComponentNote 'This is a UI5 Web Component! [Repository](https://github.com/SAP/ui5-webcomponents) | [Documentation](https://sap.github.io/ui5-webcomponents/playground/)'", + "create-webcomponents-wrapper": "yarn run wrappers:main && yarn run wrappers:fiori && prettier --log-level silent --write ./packages/main/src/webComponents && eslint --fix ./packages/main/src/webComponents/*/index.tsx", "chromatic": "cross-env STORYBOOK_ENV=chromatic npx chromatic --build-script-name build:storybook", "postinstall": "husky && yarn prepare", "create-cypress-commands-docs": "typedoc && rimraf temp/typedoc" @@ -35,9 +37,9 @@ "@storybook/react": "7.6.12", "@storybook/react-vite": "7.6.12", "@storybook/theming": "7.6.12", - "@ui5/webcomponents": "1.21.2", - "@ui5/webcomponents-fiori": "1.21.2", - "@ui5/webcomponents-icons": "1.21.2", + "@ui5/webcomponents": "1.22.0", + "@ui5/webcomponents-fiori": "1.22.0", + "@ui5/webcomponents-icons": "1.22.0", "react": "18.2.0", "react-dom": "18.2.0", "remark-gfm": "^3.0.1", @@ -55,9 +57,10 @@ "@types/node": "^20.0.0", "@types/react": "^18.2.23", "@types/react-dom": "^18.2.7", + "@types/turndown": "^5.0.4", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "@ui5/webcomponents-tools": "1.21.2", + "@ui5/webcomponents-tools": "1.22.0", "@vitejs/plugin-react": "^4.2.0", "chromatic": "^10.0.0", "cssnano": "^6.0.1", diff --git a/packages/base/package.json b/packages/base/package.json index 7432d79f1e0..33688525c26 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -30,7 +30,7 @@ }, "peerDependencies": { "@types/react": "*", - "@ui5/webcomponents-base": "~1.21.0", + "@ui5/webcomponents-base": "~1.22.0", "react": "^16.14.0 || ^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { diff --git a/packages/charts/package.json b/packages/charts/package.json index 76d3f0bc950..c5f65c076e8 100644 --- a/packages/charts/package.json +++ b/packages/charts/package.json @@ -34,8 +34,8 @@ "recharts": "2.11.0" }, "peerDependencies": { - "@ui5/webcomponents-react": "~1.24.0", - "@ui5/webcomponents-react-base": "~1.24.0", + "@ui5/webcomponents-react": "~1.25.0", + "@ui5/webcomponents-react-base": "~1.25.0", "react": "^16.14.0 || ^17.0.0 || ^18.0.0", "react-jss": "^10.10.0" }, diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 00000000000..be3f0c7e2b0 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,2 @@ +dist +test \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000000..a13dcd7e065 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,39 @@ +{ + "name": "@ui5/webcomponents-react-cli", + "private": true, + "description": "CLI for UI5 Web Components for React", + "author": "SAP SE (https://www.sap.com)", + "license": "Apache-2.0", + "version": "1.22.0", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "ui5-wcr": "./dist/bin/index.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/SAP/ui5-webcomponents-react", + "directory": "packages/cli" + }, + "scripts": { + "clean": "rimraf dist" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "dependencies": { + "dedent": "^1.5.1", + "turndown": "^7.1.2" + } +} diff --git a/packages/cli/src/bin/index.ts b/packages/cli/src/bin/index.ts new file mode 100755 index 00000000000..b2087be1d32 --- /dev/null +++ b/packages/cli/src/bin/index.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import * as process from 'process'; + +const options = { + packageName: { + type: 'string' as const + }, + out: { + type: 'string' as const, + short: 'o' + }, + additionalComponentNote: { + type: 'string' as const + } +}; +const { values, positionals } = parseArgs({ options, allowPositionals: true }); + +const [command] = positionals; + +console.log(command); + +switch (command) { + case 'create-wrappers': + const { packageName, out, additionalComponentNote } = values; + const missingParameters = []; + if (!packageName) { + missingParameters.push('--packageName'); + } + if (!out) { + missingParameters.push('--out'); + } + + if (missingParameters.length > 0) { + console.error(` + Missing parameters: ${missingParameters.join(', ')} + Example: ui5-wcr create-wrappers --packageName @ui5/webcomponents --out ./src/components + + Please add the missing parameters and try again. + `); + process.exit(1); + } + + const createWrapperModule = await import('../scripts/create-wrappers/main.js'); + + const outDir = resolve(process.cwd(), values.out!); + // eslint-disable-next-line @typescript-eslint/await-thenable + await createWrapperModule.default(packageName!, outDir, { additionalComponentNote }); + process.exit(0); + break; + default: + console.warn('Unknown command', command); + process.exit(1); +} diff --git a/packages/cli/src/scripts/create-wrappers/AbstractRenderer.ts b/packages/cli/src/scripts/create-wrappers/AbstractRenderer.ts new file mode 100644 index 00000000000..7301440533c --- /dev/null +++ b/packages/cli/src/scripts/create-wrappers/AbstractRenderer.ts @@ -0,0 +1,19 @@ +import type { WebComponentWrapper } from './WebComponentWrapper.js'; + +export enum RenderingPhase { + imports = 'imports', + attributes = 'attributes', + props = 'props', + domRef = 'domRef', + component = 'component', + exports = 'exports' +} + +export abstract class AbstractRenderer { + public readonly phase!: RenderingPhase; + + public prepare(context: WebComponentWrapper) { + // optional + } + abstract render(context: WebComponentWrapper): string; +} diff --git a/packages/cli/src/scripts/create-wrappers/AttributesRenderer.ts b/packages/cli/src/scripts/create-wrappers/AttributesRenderer.ts new file mode 100644 index 00000000000..f0cdd869a49 --- /dev/null +++ b/packages/cli/src/scripts/create-wrappers/AttributesRenderer.ts @@ -0,0 +1,102 @@ +import type * as CEM from '@ui5/webcomponents-tools/lib/cem/types-internal.d.ts'; +import dedent from 'dedent'; +import { + mapWebComponentTypeToPrimitive, + propDescriptionFormatter, + snakeCaseToCamelCase +} from '../../util/formatters.js'; +import { resolveReferenceImports } from '../../util/referenceResolver.js'; +import { AbstractRenderer, RenderingPhase } from './AbstractRenderer.js'; +import { WebComponentWrapper } from './WebComponentWrapper.js'; + +const loggedTypes = new Set(); + +function mapWebComponentTypeToTsType(type: string) { + const primitive = mapWebComponentTypeToPrimitive(type); + if (primitive) { + return primitive; + } + switch (type) { + case 'HTMLElement | string | undefined': + case 'HTMLElement | string': + // opener props only accept strings as prop types + return 'string'; + + default: + if (!loggedTypes.has(type)) { + console.log('-> Attributes type', type); + loggedTypes.add(type); + } + return type; + } +} + +export class AttributesRenderer extends AbstractRenderer { + public phase = RenderingPhase.attributes; + + private _attributes: CEM.ClassField[] = []; + + setAttributes(value: CEM.Attribute[]) { + this._attributes = value.toSorted((a, b) => a.name.localeCompare(b.name)) as CEM.ClassField[]; + return this; + } + + private descriptionBuilder(attribute: CEM.ClassField) { + const parts: string[] = []; + + parts.push(` * ${propDescriptionFormatter(attribute.description ?? '')}`); + if (attribute.default && attribute.default.length > 0 && attribute.default !== '""') { + parts.push(` * @default ${attribute.default}`); + } + + if (attribute.deprecated) { + parts.push(` * @deprecated ${attribute.deprecated}`); + } + + return `/**\n${parts.join('\n')}\n */`; + } + + private propTyping(attribute: CEM.ClassField, context: WebComponentWrapper) { + let type = attribute.type?.text ?? 'unknown'; + type = mapWebComponentTypeToTsType(type); + + const references = attribute.type?.references; + const isEnum = references != null && references?.length > 0; + + if (isEnum) { + type += ` | keyof typeof ${type}`; + } + if (attribute._ui5validator === 'CSSColor') { + type = `CSSProperties['color']`; + } else if (attribute._ui5validator === 'CSSSize') { + type = `CSSProperties['width'] | CSSProperties['height']`; + } + + context.addAttribute(snakeCaseToCamelCase(attribute.name), type); + + return `${snakeCaseToCamelCase(attribute.name)}?: ${type};`; + } + + prepare(context: WebComponentWrapper) { + for (const attribute of this._attributes) { + // special css handling + if (attribute._ui5validator === 'CSSSize' || attribute._ui5validator === 'CSSColor') { + context.addTypeImport('react', 'CSSProperties'); + } else { + resolveReferenceImports(attribute.type?.references ?? [], context); + } + } + } + + render(context: WebComponentWrapper): string { + return dedent` + interface ${context.componentName}Attributes { + ${this._attributes + .map((attribute) => { + return `${this.descriptionBuilder(attribute)}\n${this.propTyping(attribute, context)}`; + }) + .join('\n\n')} + } + `; + } +} diff --git a/packages/cli/src/scripts/create-wrappers/ComponentRenderer.ts b/packages/cli/src/scripts/create-wrappers/ComponentRenderer.ts new file mode 100644 index 00000000000..b8b7a7ca0d1 --- /dev/null +++ b/packages/cli/src/scripts/create-wrappers/ComponentRenderer.ts @@ -0,0 +1,99 @@ +import type * as CEM from '@ui5/webcomponents-tools/lib/cem/types-internal.d.ts'; +import dedent from 'dedent'; +import { snakeCaseToCamelCase, summaryFormatter } from '../../util/formatters.js'; +import { AbstractRenderer, RenderingPhase } from './AbstractRenderer.js'; +import { WebComponentWrapper } from './WebComponentWrapper.js'; + +export class ComponentRenderer extends AbstractRenderer { + public phase = RenderingPhase.component; + + private dynamicImportPath: string | undefined; + private attributes: CEM.ClassField[] = []; + private slots: CEM.Slot[] = []; + private events: CEM.Event[] = []; + private description: string = ''; + private note: string = ''; + private isAbstract: boolean = false; + + setDynamicImportPath(value: string) { + this.dynamicImportPath = value; + return this; + } + + setAttributes(attrs: CEM.ClassField[]) { + this.attributes.push(...attrs); + return this; + } + + setSlots(slots: CEM.Slot[]) { + this.slots.push(...slots); + return this; + } + + setEvents(events: CEM.Event[]) { + this.events.push(...events); + return this; + } + + setDescription(value: string) { + this.description = value; + return this; + } + + setNote(value: string) { + this.note = value; + return this; + } + + setIsAbstract(value: boolean) { + this.isAbstract = value; + return this; + } + + prepare(context: WebComponentWrapper) { + context.exportSet.add(context.componentName); + } + + render(context: WebComponentWrapper): string { + let comment = `/**\n * ${summaryFormatter(this.description)}\n *\n`; + + if (this.isAbstract) { + comment += ' * @abstract\n'; + } + if (this.note) { + comment += ` * __Note__: ${this.note}\n`; + } + comment += '*/'; + + const component = dedent` + const ${context.componentName} = withWebComponent<${context.componentName}PropTypes, ${ + context.componentName + }DomRef>('${context.tagName}', + [${this.attributes + .filter((attr) => attr.type?.text !== 'boolean') + .map((attr) => `'${snakeCaseToCamelCase(attr.name)}'`) + .toSorted((a, b) => a.localeCompare(b)) + .join(', ')}], + [${this.attributes + .filter((attr) => attr.type?.text === 'boolean') + .map((attr) => `'${snakeCaseToCamelCase(attr.name)}'`) + .toSorted((a, b) => a.localeCompare(b)) + .join(', ')}], + [${this.slots + ?.filter((slot) => slot.name !== 'default') + .map((slot) => `'${snakeCaseToCamelCase(slot.name)}'`) + .toSorted((a, b) => a.localeCompare(b)) + .join(', ')}], + [${this.events + ?.map((event) => `'${event.name}'`) + .toSorted((a, b) => a.localeCompare(b)) + .join(', ')}], + () => import('${this.dynamicImportPath}') + ); + + ${context.componentName}.displayName = '${context.componentName}'; + `; + + return [comment, component].join('\n'); + } +} diff --git a/packages/cli/src/scripts/create-wrappers/DomRefRenderer.ts b/packages/cli/src/scripts/create-wrappers/DomRefRenderer.ts new file mode 100644 index 00000000000..6e070a51258 --- /dev/null +++ b/packages/cli/src/scripts/create-wrappers/DomRefRenderer.ts @@ -0,0 +1,167 @@ +import type * as CEM from '@ui5/webcomponents-tools/lib/cem/types-internal.d.ts'; +import dedent from 'dedent'; +import { mapWebComponentTypeToPrimitive, propDescriptionFormatter } from '../../util/formatters.js'; +import { resolveReferenceImports } from '../../util/referenceResolver.js'; +import { AbstractRenderer, RenderingPhase } from './AbstractRenderer.js'; +import { WebComponentWrapper } from './WebComponentWrapper.js'; + +const loggedTypes = new Set(); + +function mapWebComponentTypeToTsType(type: string = 'unknown') { + const primitive = mapWebComponentTypeToPrimitive(type); + if (primitive) { + return primitive; + } + switch (type) { + case 'function': + return 'Function'; + case 'HTMLElement': + // we need to extend HTMLElement with | EventTarget to allow opening popovers from event handlers + // example: ); - cy.get('ui5-button').click(); - cy.get('@onClickSpy').should('have.been.calledOnce'); - }); -}); diff --git a/packages/main/src/webComponents/Button/Button.mdx b/packages/main/src/webComponents/Button/Button.mdx index ca7625a5684..0a57e67d494 100644 --- a/packages/main/src/webComponents/Button/Button.mdx +++ b/packages/main/src/webComponents/Button/Button.mdx @@ -1,6 +1,5 @@ import { Canvas, Markdown, Meta } from '@storybook/blocks'; import { ControlsWithNote, DocsHeader, DomRefTable, Footer } from '@sb/components'; -import Description from './ButtonDescription.md?raw'; import ButtonDomRef from './ButtonDomRef.json'; import * as ComponentStories from './Button.stories'; @@ -18,8 +17,6 @@ import * as ComponentStories from './Button.stories'; -{Description} -