diff --git a/docs/6-contributing/04-writing-samples.md b/docs/6-contributing/04-writing-samples.md index 003d96d48c6c..e9b42420d53b 100644 --- a/docs/6-contributing/04-writing-samples.md +++ b/docs/6-contributing/04-writing-samples.md @@ -115,7 +115,7 @@ The above example includes only the `indeterminate`, `checked` properties in the ## Documentation -The documentation for each component is automatically produced using the `custom-elements.json` file. Additionally, there is an `argTypes.ts` file located beside each `.stories.ts` file. It is generated during build time and contains extra properties that enhance the documentation beyond what is available in the `custom-elements.json` file. This file should not be edited directly, as it can only be modified by the `packages/playground/build-scripts-storybook/samples-prepare.js` script. +The documentation for each component is automatically produced using the `custom-elements.json` file. Additionally, there is an `argTypes.ts` file located beside each `.stories.ts` file. It is generated during build time and contains extra properties that enhance the documentation beyond what is available in the `custom-elements.json` file. This file should not be edited directly, as it can only be modified by the `packages/playground/build-scripts-storybook/samples-prepare.ts` script. ### Docs page Every story has a `docs` page in the storybook's sidebar. Usually, this page is generated automatically by storybook but it can be customized by adding a `docs` property to the story parameters. diff --git a/packages/playground/.storybook/args/enhanceArgTypes.ts b/packages/playground/.storybook/args/enhanceArgTypes.ts index 7426fb784800..8da8f412dfb8 100644 --- a/packages/playground/.storybook/args/enhanceArgTypes.ts +++ b/packages/playground/.storybook/args/enhanceArgTypes.ts @@ -25,6 +25,14 @@ export const enhanceArgTypes = ( ) as typeof userArgTypes) : userArgTypes; + Object.keys(withExtractedTypes) + .filter(key => key.startsWith("_ui5")) + .forEach(argType => { + withExtractedTypes[argType].name = withExtractedTypes[argType].name.replace("_ui5", ""); + + withExtractedTypes[argType].control = "text" + }) + // enhance descriptions enhanceArgTypesDescriptions(withExtractedTypes); return withExtractedTypes; diff --git a/packages/playground/.storybook/args/enhancers/description/event/EventDescriptionRenderer.tsx b/packages/playground/.storybook/args/enhancers/description/event/EventDescriptionRenderer.tsx index 302e4e3e94f5..4ab4bcbf8521 100644 --- a/packages/playground/.storybook/args/enhancers/description/event/EventDescriptionRenderer.tsx +++ b/packages/playground/.storybook/args/enhancers/description/event/EventDescriptionRenderer.tsx @@ -7,7 +7,7 @@ export class EventDescriptionRenderer implements IDescriptionRenderer { {p.name} diff --git a/packages/playground/.storybook/args/enhancers/description/method/MethodDescriptionRenderer.tsx b/packages/playground/.storybook/args/enhancers/description/method/MethodDescriptionRenderer.tsx index 56c65efeee65..1a9b50c7a1fb 100644 --- a/packages/playground/.storybook/args/enhancers/description/method/MethodDescriptionRenderer.tsx +++ b/packages/playground/.storybook/args/enhancers/description/method/MethodDescriptionRenderer.tsx @@ -8,7 +8,7 @@ export class MethodDescriptionRenderer implements IDescriptionRenderer { {p.name} @@ -31,7 +31,7 @@ export class MethodDescriptionRenderer implements IDescriptionRenderer { <>

Return Value:

diff --git a/packages/playground/.storybook/args/types.ts b/packages/playground/.storybook/args/types.ts index 099202cbd080..dbdd3e628fa6 100644 --- a/packages/playground/.storybook/args/types.ts +++ b/packages/playground/.storybook/args/types.ts @@ -8,13 +8,17 @@ export interface IArgTypeEnhancer { } export type ReturnValue = { - type: string; + type: { + text: string, + }; description: string; }; export type Parameter = { name: string; - type: string; + type: { + text: string + }; description: string; }; diff --git a/packages/playground/build-scripts-storybook/parse-manifest.js b/packages/playground/build-scripts-storybook/parse-manifest.js index 02bda20aa9c3..9e35df3a8aea 100644 --- a/packages/playground/build-scripts-storybook/parse-manifest.js +++ b/packages/playground/build-scripts-storybook/parse-manifest.js @@ -28,26 +28,33 @@ const EXCLUDE_LIST = [ ]; const loadManifest = () => { - try { - const customElementsMain = require("@ui5/webcomponents/custom-elements.json"); - const customElementsFiori = require("@ui5/webcomponents-fiori/custom-elements.json"); + let customElementsMain = {}; + let customElementsFiori = {}; + let customElementsBase = {}; - return { - customElementsMain, - customElementsFiori, - }; + try { + customElementsMain = require("@ui5/webcomponents/custom-elements.json"); } catch (error) { - console.log("Error while loading manifests. Did you run 'yarn build'?"); + console.error("Did you run `yarn build` for packages/main?") + } - if (process.env.NODE_ENV !== "production") { - return { - customElementsMain: {}, - customElementsFiori: {}, - }; - } + try { + customElementsFiori = require("@ui5/webcomponents-fiori/custom-elements.json"); + } catch (error) { + console.error("Did you run `yarn build` for packages/main?") + } - throw error; + try { + customElementsBase = require("@ui5/webcomponents-base/custom-elements.json"); + } catch (error) { + console.error("Did you run `yarn build` for packages/main?") } + + return { + customElementsMain, + customElementsFiori, + customElementsBase, + }; }; const parseMembers = (members) => { @@ -56,10 +63,6 @@ const parseMembers = (members) => { if (EXCLUDE_LIST.indexOf(member.name) > -1) { return; } - if (member.kind === "method") { - // change kind to property as Storybook does not show methods from the custom-elements.json - member.kind = "field"; - } parsed.push(member); }); return parsed; @@ -76,6 +79,15 @@ const parseModule = (module) => { if (declaration.members) { declaration.members = parseMembers(declaration.members); } + // Storybook remove slots/css parts/properties/events with duplicate names so we add suffix to css parts in order to avoid duplicates. + // It can't happen to slots and properties since you can't have duplicate accessors. + if (declaration.cssParts) { + declaration.cssParts.forEach(part => { + if (!part.name.startsWith("_ui5") ) { + part.name = `_ui5${part.name}`; + } + }); + } return declaration; }); @@ -117,7 +129,7 @@ const flattenAPIsHierarchicalStructure = module => { } const mergeClassMembers = (declaration, superclassDeclaration) => { - const props = ["members", "slots", "events"]; + const props = ["members", "slots", "events", "cssParts"]; props.forEach(prop => { if (declaration[prop]?.length) { @@ -139,11 +151,11 @@ const mergeArraysWithoutDuplicates = (currentValues, newValue) => { } -const { customElementsMain, customElementsFiori } = loadManifest(); -const customElements = mergeManifests(customElementsMain, customElementsFiori ); +const { customElementsMain, customElementsFiori, customElementsBase } = loadManifest(); +let customElements = mergeManifests(mergeManifests(customElementsMain, customElementsFiori), customElementsBase ); const processedDeclarations = new Map(); -customElements.modules.forEach(flattenAPIsHierarchicalStructure) +customElements.modules?.forEach(flattenAPIsHierarchicalStructure); fs.writeFileSync( path.join(__dirname, "../.storybook/custom-elements.json"), diff --git a/packages/playground/build-scripts-storybook/samples-prepare.js b/packages/playground/build-scripts-storybook/samples-prepare.js deleted file mode 100644 index 0176229d25c8..000000000000 --- a/packages/playground/build-scripts-storybook/samples-prepare.js +++ /dev/null @@ -1,156 +0,0 @@ -const fs = require('fs/promises'); -const path = require('path'); - -const STORIES_ROOT_FOLDER_NAME = '../_stories'; -const NUMERIC_TYPES = ["sap.ui.webc.base.types.Integer", "sap.ui.webc.base.types.Float"]; -const STRING_TYPES = ["sap.ui.webc.base.types.CSSColor", "sap.ui.webc.base.types.DOMReference"]; - -// run the script to generate the argTypes for the stories available in the _stories folder -const main = async () => { - - const baseAPI = JSON.parse((await fs.readFile(`../base/dist/api.json`)).toString()); - - // read all directories inside _stories folder and create a list of components - const packages = await fs.readdir(path.join(__dirname, STORIES_ROOT_FOLDER_NAME)); - for (const package of packages) { - // packages [main, fiori] - const api = JSON.parse((await fs.readFile(`../${package}/dist/api.json`)).toString()); - - const packagePath = path.join(__dirname, STORIES_ROOT_FOLDER_NAME, package); - const packageStats = await fs.stat(packagePath); - if (packageStats.isDirectory()) { - const componentsInPackage = await fs.readdir(packagePath); - for (const component of componentsInPackage) { - // components [Button, Card, ...] - const componentPath = path.join(packagePath, component); - const componentStats = await fs.stat(componentPath); - if (componentStats.isDirectory()) { - generateStoryDoc(componentPath, component, api, package); - } - } - } - } - - async function generateStoryDoc(componentPath, component, api, package) { - console.log(`Generating argTypes for story ${component}`); - const apiData = getAPIData(api, component, package); - const { storyArgsTypes, slotNames, info } = apiData; - - await fs.writeFile(componentPath + '/argTypes.ts', `export default ${storyArgsTypes}; -export const componentInfo = ${JSON.stringify(info, null, 4)}; -export type StoryArgsSlots = { - ${slotNames.map(slotName => `${slotName}: string;`).join('\n ')} -}`); - }; - - function getAPIData(api, module, package) { - const moduleAPI = api.symbols.find(s => s.module === module); - const data = getArgsTypes(api, moduleAPI); - - return { - info: { - package: `@ui5/webcomponents${package !== 'main' ? `-${package}` : ''}`, - since: moduleAPI.since - }, - slotNames: data.slotNames, - storyArgsTypes: JSON.stringify(data.args, null, "\t") - }; - } - - function getArgsTypes(api, moduleAPI) { - let args = {}; - let slotNames = []; - - moduleAPI?.properties?.forEach(prop => { - if (prop.visibility === 'public') { - const typeEnum = api.symbols.find(s => s.name === prop.type) || baseAPI.symbols.find(s => s.name === prop.type); - if (prop.readonly) { - args[prop.name] = { - control: { - type: false - }, - }; - } else if (Array.isArray(typeEnum?.properties)) { - args[prop.name] = { - control: "select", - options: typeEnum.properties.map(a => a.type), - }; - } else if (NUMERIC_TYPES.includes(typeEnum?.name)) { - args[prop.name] = { - control: { - type: "number" - }, - }; - } else if (STRING_TYPES.includes(typeEnum?.name)) { - args[prop.name] = { - control: { - type: "text" - }, - }; - } - } - }); - - moduleAPI?.slots?.forEach(prop => { - if (prop.visibility === 'public') { - args[prop.name] = { - control: { - type: "text" - } - }; - slotNames.push(prop.name); - } - }); - - // methods parsing because Storybook does not include them in the args by default from the custom-elements.json - // only changing the category to Methods so they are not displayed in the Properties tab - moduleAPI?.methods?.forEach((prop) => { - if (prop.visibility === "public") { - args[prop.name] = { - description: prop.description, - table: { - category: "methods", - }, - }; - - // methods can have custom descriptions with parameters and return value - if (prop.parameters || prop.returnValue) { - args[prop.name].UI5CustomData = { - parameters: prop.parameters, - returnValue: prop.returnValue, - } - } - } - }); - - // events also have custom descriptions with parameters of their detail object - moduleAPI?.events?.forEach((prop) => { - if (prop.visibility === "public" && prop.parameters) { - args[prop.name] = { - description: prop.description, - table: { - category: "events", - }, - UI5CustomData: { - parameters: prop.parameters, - }, - }; - } - }); - - // recursively merging the args from the parent/parents - const moduleAPIBeingExtended = api.symbols.find(s => s.name === moduleAPI.extends) || baseAPI.symbols.find(s => s.module === moduleAPI.extends); - if (moduleAPIBeingExtended) { - const { args: nextArgs, slotNames: nextSlotNames } = getArgsTypes(api, moduleAPIBeingExtended); - args = { ...args, ...nextArgs }; - slotNames = [...slotNames, ...nextSlotNames].filter((v, i, a) => a.indexOf(v) === i); - } - - return { - args, - slotNames - }; - } -}; - -main(); diff --git a/packages/playground/build-scripts-storybook/samples-prepare.ts b/packages/playground/build-scripts-storybook/samples-prepare.ts new file mode 100644 index 000000000000..47ead0ffa790 --- /dev/null +++ b/packages/playground/build-scripts-storybook/samples-prepare.ts @@ -0,0 +1,240 @@ +import fs from "fs/promises"; +import path from "path"; +import type { + ClassDeclaration, + CustomElementDeclaration, + MySchema, + Parameter, + Type, + ClassField, + ClassMethod, + EnumDeclaration, + InterfaceDeclaration, + FunctionDeclaration, + CustomElementMixinDeclaration, + MixinDeclaration, + VariableDeclaration +} from "@ui5/webcomponents-tools/lib/cem/types.js"; + +const STORIES_ROOT_FOLDER_NAME = '../_stories'; + +const isCustomElementDeclaration = (object: any): object is CustomElementDeclaration => { + return "customElement" in object && object.customElement; +}; + +type Declaration = CustomElementDeclaration | EnumDeclaration | ClassDeclaration | InterfaceDeclaration | FunctionDeclaration | MixinDeclaration | VariableDeclaration | CustomElementMixinDeclaration + +type ControlType = "text" | "select" | "multi-select" | boolean; + +type ArgsTypes = Record>; + name?: string; + options?: string[]; + table?: { + category?: string; + defaultValue?: { summary: string; detail?: string }; + subcategory?: string; + type?: { summary?: string; detail?: string }; + }; + UI5CustomData?: { + parameters?: Array; + returnValue?: { + description?: string; + summary?: string; + type?: Type; + }; + }; +}>; + +type APIData = { + info: { + package: string; + since: string | undefined; + }; + slotNames: Array; + storyArgsTypes: string; +}; + +// run the script to generate the argTypes for the stories available in the _stories folder +const main = async () => { + const api: MySchema = JSON.parse((await fs.readFile(`./.storybook/custom-elements.json`)).toString()); + + // read all directories inside _stories folder and create a list of components + const packages = await fs.readdir(path.join(__dirname, STORIES_ROOT_FOLDER_NAME)); + for (const currPackage of packages) { + // packages [main, fiori] + const packagePath = path.join(__dirname, STORIES_ROOT_FOLDER_NAME, currPackage); + const packageStats = await fs.stat(packagePath); + if (packageStats.isDirectory()) { + const componentsInPackage = await fs.readdir(packagePath); + for (const component of componentsInPackage) { + // components [Button, Card, ...] + const componentPath = path.join(packagePath, component); + const componentStats = await fs.stat(componentPath); + if (componentStats.isDirectory()) { + generateStoryDoc(componentPath, component, api, currPackage); + } + } + } + } +}; + +const generateStoryDoc = async (componentPath: string, component: string, api: MySchema, componentPackage: string) => { + console.log(`Generating argTypes for story ${component}`); + const apiData = getAPIData(api, component, componentPackage); + + if (!apiData) { + return; + } + + const { storyArgsTypes, slotNames, info } = apiData; + + await fs.writeFile(componentPath + '/argTypes.ts', `export default ${storyArgsTypes}; +export const componentInfo = ${JSON.stringify(info, null, 4)}; +export type StoryArgsSlots = { + ${slotNames.map(slotName => `${slotName}: string;`).join('\n ')} +}`); +}; + +const getAPIData = (api: MySchema, module: string, componentPackage: string): APIData | undefined => { + const moduleAPI = api.modules?.find(currModule => currModule.declarations?.find(s => s._ui5reference?.name === module && s._ui5reference?.package === `@ui5/webcomponents${componentPackage !== 'main' ? `-${componentPackage}` : ''}`)); + const declaration = moduleAPI?.declarations?.find(s => s._ui5reference?.name === module && s._ui5reference?.package === `@ui5/webcomponents${componentPackage !== 'main' ? `-${componentPackage}` : ''}`); + + if (!declaration) { + return; + } + + const data = getArgsTypes(api, declaration as CustomElementDeclaration); + + return { + info: { + package: `@ui5/webcomponents${componentPackage !== 'main' ? `-${componentPackage}` : ''}`, + since: declaration?._ui5since + }, + slotNames: data.slotNames, + storyArgsTypes: JSON.stringify(data.args, null, "\t") + }; +}; + +const getArgsTypes = (api: MySchema, moduleAPI: CustomElementDeclaration | ClassDeclaration) => { + let args: ArgsTypes = {}; + let slotNames: string[] = []; + + moduleAPI.members + ?.filter((member): member is ClassField => "kind" in member && member.kind === "field") + .forEach(prop => { + let typeEnum: EnumDeclaration | undefined; + + if (prop.type?.references?.length) { + for (const currModule of api.modules) { + if (!currModule.declarations) { + continue; + } + + for (const s of currModule.declarations) { + if (s?._ui5reference?.name === prop.type?.references[0].name && s._ui5reference?.package === prop.type?.references[0].package && s.kind === "enum") { + typeEnum = s; + break; + } + } + } + } + + if (prop.readonly) { + args[prop.name] = { + control: { + type: false + }, + }; + } else if (typeEnum && Array.isArray(typeEnum.members)) { + args[prop.name] = { + control: "select", + options: typeEnum.members.map(a => a.name), + }; + } + }); + + if (isCustomElementDeclaration(moduleAPI)) { + moduleAPI.slots?.forEach(prop => { + args[prop.name] = { + control: { + type: "text" + } + }; + slotNames.push(prop.name); + }); + } + + moduleAPI.members + ?.filter((member): member is ClassMethod => "kind" in member && member.kind === "method") + .forEach((prop) => { + args[prop.name] = { + description: prop.description, + table: { + category: "methods", + }, + }; + + if (prop.parameters || prop.return) { + args[prop.name].UI5CustomData = { + parameters: prop.parameters, + returnValue: prop.return, + }; + } + + (prop as unknown as ClassField).kind = "field"; + }); + + // events also have custom descriptions with parameters of their detail objec + if (isCustomElementDeclaration(moduleAPI)) { + moduleAPI.events?.forEach((prop) => { + if (prop.privacy === "public" && prop.params?.length) { + args[prop.name] = { + description: prop.description, + table: { + category: "events", + }, + UI5CustomData: { + parameters: prop.params, + }, + }; + } + }); + } + + const packages = ["@ui5/webcomponents", "@ui5/webcomponents-fiori"]; + + // recursively merging the args from the parent/parents + let moduleAPIBeingExtended; + + if (moduleAPI.superclass && api.modules) { + for (const currModule of api.modules) { + if (!currModule.declarations || !moduleAPI.superclass?.name || !moduleAPI.superclass?.package) { + continue; + } + + moduleAPIBeingExtended = findReference(currModule.declarations, moduleAPI.superclass?.name, moduleAPI.superclass.package); + } + } + + const referencePackage = moduleAPIBeingExtended?._ui5reference?.package; + + if (moduleAPIBeingExtended && referencePackage && packages.includes(referencePackage)) { + const { args: nextArgs, slotNames: nextSlotNames } = getArgsTypes(api, moduleAPIBeingExtended as ClassDeclaration); + args = { ...args, ...nextArgs }; + slotNames = [...slotNames, ...nextSlotNames].filter((v, i, a) => a.indexOf(v) === i); + } + + return { + args, + slotNames + }; +}; + +const findReference = (something: Array, componentName: string, componentPackage: string): Declaration | undefined => { + return something.find(s => s._ui5reference?.name === componentName && s._ui5reference?.package === componentPackage) +} + +main(); diff --git a/packages/playground/package.json b/packages/playground/package.json index 25a9a747a580..06954d4dbc82 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -51,7 +51,7 @@ "prepare:build:nojekyll": "copy-and-watch \"./.nojekyll\" \"./dist\"", "prepare:build:pages": "npm run clean:pages && vite-node ./build-scripts-storybook/pages-prepare.ts", "prepare:assets": "npm run clean:assets && npm run copy:assets", - "prepare:samples": "node ./build-scripts-storybook/samples-prepare.js", + "prepare:samples": "vite-node ./build-scripts-storybook/samples-prepare.ts", "prepare:manifest": "node ./build-scripts-storybook/parse-manifest.js", "prepare:documentation": "vite-node ./build-scripts-storybook/documentation-prepare.ts", "storybook": "npm-run-all --parallel prepare:* && storybook dev -p 6006",