├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .storybook ├── CloudBlueTheme.js ├── global-styles.css ├── main.js ├── manager.js ├── preview.js └── stories.css ├── LICENSE ├── README.md ├── components ├── src │ ├── assets │ │ └── styles │ │ │ ├── common.styl │ │ │ └── variables.styl │ ├── composables │ │ ├── validation.js │ │ └── validation.spec.js │ ├── constants │ │ ├── color.js │ │ ├── dialogs.js │ │ └── portal-routes.js │ ├── core │ │ ├── eventBus.js │ │ ├── helpers.js │ │ ├── helpers.spec.js │ │ ├── injector │ │ │ ├── core │ │ │ │ ├── Core.js │ │ │ │ ├── Core.spec.js │ │ │ │ ├── injectorFactory.js │ │ │ │ ├── injectorFactory.spec.js │ │ │ │ ├── launcher.js │ │ │ │ └── launcher.spec.js │ │ │ ├── index.js │ │ │ └── index.spec.js │ │ ├── registerWidget.js │ │ ├── router.js │ │ ├── router.spec.js │ │ └── store.js │ ├── index.js │ ├── index.spec.js │ ├── stories │ │ ├── Alert.stories.js │ │ ├── Autocomplete.stories.js │ │ ├── Button.stories.js │ │ ├── Card.stories.js │ │ ├── ComplexTable.stories.js │ │ ├── Dialog.stories.js │ │ ├── Icon.stories.js │ │ ├── Introduction.mdx │ │ ├── Menu.stories.js │ │ ├── Navigation.stories.js │ │ ├── Radio.stories.js │ │ ├── Select.stories.js │ │ ├── Status.stories.js │ │ ├── Table.stories.js │ │ ├── Tabs.stories.js │ │ ├── TextField.stories.js │ │ ├── Textarea.stories.js │ │ └── View.stories.js │ └── widgets │ │ ├── alert │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── autocomplete │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── button │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── card │ │ └── widget.vue │ │ ├── complexTable │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── dialog │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── icon │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── menu │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── navigation │ │ └── widget.vue │ │ ├── pad │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── radio │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── select │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── status │ │ └── widget.vue │ │ ├── tab │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── table │ │ └── widget.vue │ │ ├── tabs │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── textarea │ │ ├── widget.spec.js │ │ └── widget.vue │ │ ├── textfield │ │ ├── widget.spec.js │ │ └── widget.vue │ │ └── view │ │ ├── widget.spec.js │ │ └── widget.vue └── vite.config.js ├── package-lock.json ├── package.json ├── public └── .gitkeep ├── sonar-project.properties ├── tools ├── api │ └── fastApi │ │ ├── adapter.js │ │ ├── adapter.spec.js │ │ ├── vue-composable.js │ │ └── vue-composable.spec.js ├── build │ └── vite │ │ ├── flatten-html-pages-directory.js │ │ ├── flatten-html-pages-directory.spec.js │ │ ├── index.js │ │ └── index.spec.js ├── vite.config.js └── vue │ ├── toolkit.js │ └── toolkit.spec.js ├── vite.config.js ├── vitest-global-setup.js └── vitest.config.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | require('@rushstack/eslint-patch/modern-module-resolution'); 2 | 3 | module.exports = { 4 | root: true, 5 | 6 | // Ignore every file but the patterns specified after '/*' 7 | ignorePatterns: [ 8 | '/*', 9 | '!/components', // all files inside /components 10 | '!/tools', // all files inside /tools 11 | '!/*.js', // all JS files in root dir 12 | ], 13 | 14 | extends: [ 15 | 'plugin:vue/vue3-recommended', 16 | 'eslint:recommended', 17 | 'plugin:storybook/recommended', 18 | 'prettier', 19 | '@vue/eslint-config-prettier/skip-formatting', 20 | 'plugin:vitest-globals/recommended', 21 | ], 22 | 23 | plugins: ['vue'], 24 | parser: 'vue-eslint-parser', 25 | 26 | rules: { 27 | 'vue/multi-word-component-names': 'off', 28 | 'vue/no-deprecated-slot-attribute': 'off', 29 | 'vue/block-order': [ 30 | 'error', 31 | { 32 | order: ['template', 'script', 'style'], 33 | }, 34 | ], 35 | 'vue/attribute-hyphenation': ['error', 'never'], 36 | }, 37 | 38 | env: { 39 | 'vitest-globals/env': true, 40 | }, 41 | 42 | parserOptions: { 43 | ecmaVersion: 'latest', 44 | }, 45 | 46 | overrides: [ 47 | // Config for files that run in node env (config files, etc) 48 | { 49 | files: ['*.config.js', '.eslintrc.js'], 50 | env: { 51 | node: true, 52 | }, 53 | }, 54 | ], 55 | }; 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | tags: 7 | - '*' 8 | 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node: ['18', '20'] 18 | name: Node ${{ matrix.node }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node }} 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Lint 29 | run: npm run lint 30 | - name: Check formatting with Prettier 31 | run: npm run format:check 32 | - name: Testing 33 | run: npm run test 34 | sonar: 35 | name: Sonar Testing 36 | needs: [test] 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | - name: Setup node 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: 20 45 | - name: Install dependencies 46 | run: npm ci 47 | - name: Testing 48 | run: npm run test:coverage 49 | - uses: SonarSource/sonarcloud-github-action@master 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 53 | - name: Wait sonar to process report 54 | uses: jakejarvis/wait-action@master 55 | with: 56 | time: '30s' 57 | - name: SonarQube Quality Gate check 58 | uses: sonarsource/sonarqube-quality-gate-action@master 59 | timeout-minutes: 5 60 | env: 61 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 62 | chromatic: 63 | name: Publish to Chromatic 64 | needs: [sonar] 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Checkout code 68 | uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | - name: Install dependencies 72 | run: npm ci 73 | - name: Publish to Chromatic 74 | uses: chromaui/action@latest 75 | with: 76 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 77 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | name: Running Publish Build 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Build 22 | run: npm run build 23 | - name: Testing 24 | run: npm test 25 | - name: Publish NPM package 26 | uses: JS-DevTools/npm-publish@v3 27 | with: 28 | token: ${{ secrets.NPM_TOKEN }} 29 | access: 'public' 30 | dry-run: false 31 | strategy: all 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist-ssr 13 | coverage 14 | *.local 15 | 16 | /test/report.json 17 | 18 | /cypress/videos/ 19 | /cypress/screenshots/ 20 | 21 | /storybook-static 22 | yarn.lock 23 | 24 | # Distribution / packaging 25 | /dist 26 | 27 | # Editor directories and files 28 | .vscode/* 29 | !.vscode/extensions.json 30 | .idea 31 | *.suo 32 | *.ntvs* 33 | *.njsproj 34 | *.sln 35 | *.sw? 36 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Except 5 | 6 | # Distribution files 7 | !/dist/**/* 8 | 9 | # License file, package.json and readme 10 | !LICENSE 11 | !package.json 12 | !README.md 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "singleQuote": true, 7 | "printWidth": 100, 8 | "trailingComma": "all", 9 | "singleAttributePerLine": true 10 | } 11 | -------------------------------------------------------------------------------- /.storybook/CloudBlueTheme.js: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming'; 2 | 3 | export default create({ 4 | base: 'light', 5 | brandTitle: 'CloudBlue Connect', 6 | brandUrl: 'https://connect.cloudblue.com', 7 | brandImage: 8 | 'https://connect.cloudblue.com/wp-content/uploads/2020/07/ClouBlue-Standalone-Logo-Blue.png', 9 | brandTarget: '_self', 10 | }); 11 | -------------------------------------------------------------------------------- /.storybook/global-styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap'); 2 | 3 | body { 4 | font-family: 'Roboto', sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | export default { 2 | stories: [ 3 | '../components/src/**/*.stories.@(js|jsx|ts|tsx|vue)', 4 | '../components/src/stories/**/*.mdx', 5 | ], 6 | 7 | staticDirs: ['../public'], 8 | 9 | addons: [ 10 | '@storybook/addon-links', 11 | '@storybook/addon-essentials', 12 | '@storybook/addon-interactions', 13 | { name: '@storybook/addon-designs', options: { renderTarget: 'tab' } }, 14 | ], 15 | 16 | framework: { 17 | name: '@storybook/vue3-vite', 18 | options: { 19 | builder: { 20 | viteConfigPath: './components/vite.config.js', 21 | }, 22 | }, 23 | }, 24 | 25 | features: { 26 | interactionsDebugger: true, 27 | }, 28 | 29 | docs: { 30 | autodocs: true, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import cloudBlueTheme from './CloudBlueTheme'; 3 | 4 | addons.setConfig({ 5 | theme: cloudBlueTheme, 6 | }); 7 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import './global-styles.css'; 2 | import './stories.css'; 3 | 4 | export const parameters = { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.storybook/stories.css: -------------------------------------------------------------------------------- 1 | .sb-all-icons { 2 | margin-top: 12px; 3 | width: 100%; 4 | display: flex; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .sb-icon-wrapper { 9 | width: calc(33% - 10px); 10 | margin-bottom: 20px; 11 | margin-right: 10px; 12 | display: flex; 13 | align-items: center; 14 | } 15 | .sb-icon { 16 | margin-right: 4px; 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connect UI Toolkit 2 | 3 | NPM package 4 | CI build 5 | Sonar Quality Gate 6 | Sonar Test Coverage 7 | 8 | --- 9 | 10 | Build your Connect Extension UI easily with our UI Toolkit. Feel free to use any frontend library 11 | or framework you prefer! 12 | 13 | ## Installation 14 | 15 | ### Minimalistic via CDN 16 | 17 | Just plug a module via `script` tag, import default exported function and call it. You're good. 18 | 19 | N.B.: For development mode - by default `` will be `http://localhost:3003` 20 | 21 | ```html 22 | 27 | ``` 28 | 29 | This will implement minimalistic interaction with parent Connect Application. 30 | 31 | ## Usage 32 | 33 | ### Use widgets 34 | 35 | 1. Import required widget from named exports 36 | 2. Pass a configuration Object to `createApp` function as an argument 37 | 3. Configuration object should contain desired tag name as a `key` and widget descriptor object as a `value`. N.B.: widget name should contain at least one "-" 38 | 39 | ```html 40 | 47 | 48 | ... 49 | 50 | 51 |

My content here...

52 |
53 | ``` 54 | 55 | Control widgets using attributes (see widgets documentation) 56 | 57 | ### Interaction with parent app 58 | 59 | We implemented two ways to interact with parent application - one is data-based, another events-based. 60 | You will find supported data properties and handled events list in slot's documentation. 61 | Let's see how you can use it to build your app: 62 | 63 | ### Data-based interface with `watch/commit` 64 | 65 | If some data-based interface is documented for particular slot 66 | you may subscribe on it using `watch` method or publish changes using `commit` 67 | 68 | ```html 69 | 82 | ``` 83 | 84 | Use `watch('observed', (value) => { ... })` to watch `observed` property 85 | 86 | Use `watch('*', (all) => { ... })` or just `watch((all) => { ... })` to watch all provided 87 | properties at once 88 | 89 | Use `commit({ observed: 'ABC' })` to commit values that you want to be sent to parent app. 90 | 91 | **N.B.: Only expected properties will be processed. Anything unexpected will be omitted** 92 | 93 | **N.B.2: Due to security reasons this tool supports only simple values - like Strings, Numbers and Booleans (in depth too). 94 | Functions, Dates etc. will not work.** 95 | 96 | ### Events-based interface with `listen/emit`; 97 | 98 | ```html 99 | 113 | ``` 114 | -------------------------------------------------------------------------------- /components/src/assets/styles/common.styl: -------------------------------------------------------------------------------- 1 | @import 'variables.styl'; -------------------------------------------------------------------------------- /components/src/assets/styles/variables.styl: -------------------------------------------------------------------------------- 1 | border-color = #e0e0e0; 2 | base-text-color = #212121; -------------------------------------------------------------------------------- /components/src/composables/validation.js: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue'; 2 | 3 | export const useFieldValidation = (model, rules) => { 4 | const isValid = ref(true); 5 | const errorMessages = ref([]); 6 | 7 | const errorMessagesString = computed(() => { 8 | if (errorMessages.value.length) return `${errorMessages.value.join('. ')}.`; 9 | 10 | return ''; 11 | }); 12 | 13 | const validateField = (value) => { 14 | const results = rules.map((rule) => rule(value)); 15 | 16 | if (results.every((result) => result === true)) { 17 | errorMessages.value = []; 18 | isValid.value = true; 19 | } else { 20 | errorMessages.value = results.filter((result) => typeof result === 'string'); 21 | isValid.value = false; 22 | } 23 | }; 24 | 25 | watch(model, validateField); 26 | 27 | return { isValid, errorMessages, errorMessagesString, validateField }; 28 | }; 29 | -------------------------------------------------------------------------------- /components/src/composables/validation.spec.js: -------------------------------------------------------------------------------- 1 | import { ref, nextTick } from 'vue'; 2 | 3 | import { useFieldValidation } from './validation'; 4 | 5 | describe('validation composables', () => { 6 | describe('useFieldValidation', () => { 7 | let model; 8 | let rule; 9 | let rules; 10 | let instance; 11 | 12 | beforeEach(() => { 13 | model = ref(''); 14 | rule = vi.fn().mockReturnValue(true); 15 | rules = [rule]; 16 | }); 17 | 18 | it('returns the required properties', () => { 19 | const { isValid, errorMessages, errorMessagesString, validateField } = useFieldValidation( 20 | model, 21 | rules, 22 | ); 23 | 24 | expect(isValid.value).toEqual(true); 25 | expect(errorMessages.value).toEqual([]); 26 | expect(errorMessagesString.value).toEqual(''); 27 | expect(validateField).toEqual(expect.any(Function)); 28 | }); 29 | 30 | describe('validateField function', () => { 31 | beforeEach(() => { 32 | instance = useFieldValidation(model, rules); 33 | }); 34 | 35 | it('validates the model value against the rules', () => { 36 | instance.validateField('foo bar baz'); 37 | 38 | expect(rule).toHaveBeenCalledWith('foo bar baz'); 39 | }); 40 | 41 | describe('if the validation is successful', () => { 42 | beforeEach(() => { 43 | rule.mockReturnValue(true); 44 | 45 | instance.validateField('foo bar baz'); 46 | }); 47 | 48 | it('sets isValid to true', () => { 49 | expect(instance.isValid.value).toEqual(true); 50 | }); 51 | 52 | it('sets errorMessages to an empty array', () => { 53 | expect(instance.errorMessages.value).toEqual([]); 54 | }); 55 | }); 56 | 57 | describe('if the validation fails', () => { 58 | beforeEach(() => { 59 | rule.mockReturnValue('You failed miserably'); 60 | 61 | instance.validateField('foo bar baz'); 62 | }); 63 | 64 | it('sets isValid to false', () => { 65 | expect(instance.isValid.value).toEqual(false); 66 | }); 67 | 68 | it('sets errorMessages as an array of all failure messages', () => { 69 | expect(instance.errorMessages.value).toEqual(['You failed miserably']); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('when the model value changes', () => { 75 | beforeEach(() => { 76 | instance = useFieldValidation(model, rules); 77 | }); 78 | 79 | it('validates the model value against the rules', async () => { 80 | model.value = 'foo bar baz'; 81 | await nextTick(); 82 | 83 | expect(rule).toHaveBeenCalledWith('foo bar baz'); 84 | }); 85 | 86 | describe('if the validation is successful', () => { 87 | beforeEach(async () => { 88 | rule.mockReturnValue(true); 89 | 90 | model.value = 'foo bar baz'; 91 | await nextTick(); 92 | }); 93 | 94 | it('sets isValid to true', () => { 95 | expect(instance.isValid.value).toEqual(true); 96 | }); 97 | 98 | it('sets errorMessages to an empty array', () => { 99 | expect(instance.errorMessages.value).toEqual([]); 100 | }); 101 | }); 102 | 103 | describe('if the validation fails', () => { 104 | beforeEach(async () => { 105 | rule.mockReturnValue('You failed miserably'); 106 | 107 | model.value = 'foo bar baz'; 108 | await nextTick(); 109 | }); 110 | 111 | it('sets isValid to false', () => { 112 | expect(instance.isValid.value).toEqual(false); 113 | }); 114 | 115 | it('sets errorMessages as an array of all failure messages', () => { 116 | expect(instance.errorMessages.value).toEqual(['You failed miserably']); 117 | }); 118 | }); 119 | }); 120 | 121 | describe('errorMessagesString computed', () => { 122 | let instance; 123 | 124 | beforeEach(() => { 125 | instance = useFieldValidation(model, rules); 126 | }); 127 | 128 | it('returns an empty string if errorMessages is empty', async () => { 129 | instance.errorMessages.value = []; 130 | await nextTick(); 131 | 132 | expect(instance.errorMessagesString.value).toEqual(''); 133 | }); 134 | 135 | it('returns the joined messages in errorMessages otherwise', async () => { 136 | instance.errorMessages.value = ['Bad value', 'Big mistake here']; 137 | await nextTick(); 138 | 139 | expect(instance.errorMessagesString.value).toEqual('Bad value. Big mistake here.'); 140 | }); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /components/src/constants/color.js: -------------------------------------------------------------------------------- 1 | export const COLORS_DICT = { 2 | NICE_GREEN: '#0BB071', 3 | NICE_RED: '#FF6A6A', 4 | MIDDLE_GREY: '#BDBDBD', 5 | DARK_GREY: '#666666', 6 | NICE_BLUE: '#2C98F0', 7 | DARKER_BLUE: '#4797f2', 8 | TEXT: '#212121', 9 | WHITE: '#FFFFFF', 10 | TRANSPARENT: 'transparent', 11 | }; 12 | -------------------------------------------------------------------------------- /components/src/constants/dialogs.js: -------------------------------------------------------------------------------- 1 | export const ACTIONS_DICT = { 2 | CANCEL: 'cancel', 3 | CLOSE: 'close', 4 | SAVE: 'save', 5 | SUBMIT: 'submit', 6 | DELETE: 'delete', 7 | SPACER: 'spacer', 8 | NEXT: 'next', 9 | BACK: 'back', 10 | }; 11 | 12 | export const ACTIONS_LABELS = { 13 | [ACTIONS_DICT.CANCEL]: 'Cancel', 14 | [ACTIONS_DICT.CLOSE]: 'Close', 15 | [ACTIONS_DICT.SAVE]: 'Save', 16 | [ACTIONS_DICT.SUBMIT]: 'Submit', 17 | [ACTIONS_DICT.DELETE]: 'Delete', 18 | [ACTIONS_DICT.NEXT]: 'Next', 19 | [ACTIONS_DICT.BACK]: 'Back', 20 | }; 21 | -------------------------------------------------------------------------------- /components/src/constants/portal-routes.js: -------------------------------------------------------------------------------- 1 | const routes = { 2 | dashboard: 'dashboard', 3 | userProfile: 'userProfile', 4 | settings: 'settings', 5 | 6 | devops: 'devops', 7 | extensions: 'extensions', 8 | extensionDevops: { 9 | name: 'devops.services.details', 10 | requires: 'id', 11 | }, 12 | extensionSettings: { 13 | name: 'settings.extensions', 14 | requires: 'id', 15 | }, 16 | 17 | subscriptions: 'subscriptions', 18 | subscriptionDetails: { 19 | name: 'subscriptions.directory.details', 20 | requires: 'id', 21 | }, 22 | fulfillmentRequests: { 23 | name: 'subscriptions', 24 | tab: 'fulfillment', 25 | }, 26 | fulfillmentRequestDetails: { 27 | name: 'subscriptions.fulfillment.details', 28 | requires: 'id', 29 | }, 30 | subscriptionsBillingRequests: { 31 | name: 'subscriptions', 32 | tab: 'billing', 33 | }, 34 | subscriptionsBillingRequestDetails: { 35 | name: 'subscriptions.billing.details', 36 | requires: 'id', 37 | }, 38 | tierConfigs: 'tierConfigs', 39 | tierConfigDetails: { 40 | name: 'tierConfigs.directory.details', 41 | requires: 'id', 42 | }, 43 | tierConfigRequests: { 44 | name: 'tierConfigs', 45 | tab: 'requests', 46 | }, 47 | tierConfigRequestDetails: { 48 | name: 'tierConfigs.requests.details', 49 | requires: 'id', 50 | }, 51 | products: 'products', 52 | productDetails: { 53 | name: 'product', 54 | requires: 'id', 55 | }, 56 | productItems: { 57 | name: 'product.items', 58 | requires: 'id', 59 | }, 60 | productParameters: { 61 | name: 'product.parameters', 62 | requires: 'id', 63 | }, 64 | productSettings: { 65 | name: 'product.settings', 66 | requires: 'id', 67 | }, 68 | productEmbedding: { 69 | name: 'product.embedding', 70 | requires: 'id', 71 | }, 72 | productVersions: { 73 | name: 'product.versions', 74 | requires: 'id', 75 | }, 76 | productLocalization: { 77 | name: 'product.localization', 78 | requires: 'id', 79 | }, 80 | productSSO: { 81 | name: 'product.ssoServices', 82 | requires: 'id', 83 | }, 84 | 85 | catalog: 'catalog', 86 | 87 | customers: 'customers', 88 | customerDetails: { 89 | name: 'customers.directory.details', 90 | requires: 'id', 91 | }, 92 | customerRequests: { 93 | name: 'customers', 94 | tab: 'requests', 95 | }, 96 | customerRequestsDetails: { 97 | name: 'customers.requests.details', 98 | requires: 'id', 99 | }, 100 | 101 | pricing: 'pricings', 102 | pricingDetails: { 103 | name: 'pricings.lists.details', 104 | requires: 'id', 105 | }, 106 | 107 | offers: 'offers', 108 | offerDetails: { 109 | name: 'offers.details', 110 | requires: 'id', 111 | }, 112 | 113 | helpdesk: 'helpdesk', 114 | helpdeskCaseDetails: { 115 | name: 'helpdesk.cases.details', 116 | requires: 'id', 117 | }, 118 | 119 | news: 'news', 120 | 121 | pim: 'pim', 122 | pimAttributes: 'pim.attributes', 123 | pimAttributeDetails: { 124 | name: 'pim.attributes.details', 125 | requires: 'id', 126 | }, 127 | pimGroups: 'pim.groups', 128 | pimGroupDetails: { 129 | name: 'pim.groups.details', 130 | requires: 'id', 131 | }, 132 | pimClassDetails: { 133 | name: 'pim.classes.details', 134 | requires: 'id', 135 | }, 136 | pimCategoryDetails: { 137 | name: 'pim.categories.details', 138 | requires: 'id', 139 | }, 140 | pimVariants: 'pim.variants', 141 | pimVariantDetails: { 142 | name: 'pim.variants.details', 143 | requires: 'id', 144 | }, 145 | 146 | marketplaces: 'marketplaces', 147 | marketplaceDetails: { 148 | name: 'marketplaces.details', 149 | requires: 'id', 150 | }, 151 | hubs: 'hubs', 152 | hubDetails: { 153 | name: 'hubs.details', 154 | requires: 'id', 155 | }, 156 | 157 | localizationContexts: { 158 | name: 'localization', 159 | tab: 'contexts', 160 | }, 161 | localizationTranslations: { 162 | name: 'localization', 163 | tab: 'translations', 164 | }, 165 | localizationTranslationDetails: { 166 | name: 'localization.translations.details', 167 | requires: 'id', 168 | }, 169 | localizationLocales: { 170 | name: 'localization', 171 | tab: 'locales', 172 | }, 173 | 174 | usage: 'usages', 175 | usageDetails: { 176 | name: 'usages.details', 177 | requires: 'id', 178 | }, 179 | 180 | listings: 'listings', 181 | listingsRequests: { 182 | name: 'listings', 183 | tab: 'requests', 184 | }, 185 | listingDetails: { 186 | name: 'listings.directory.details', 187 | requires: 'id', 188 | }, 189 | listingsRequestDetails: { 190 | name: 'listings.requests.details', 191 | requires: 'id', 192 | }, 193 | 194 | integrations: 'integrations', 195 | integrationsWebhooks: 'integrations.webhooks', 196 | integrationsTokens: 'integrations.tokens', 197 | integrationsExtensions: 'integrations.extensions', 198 | 199 | reports: 'reports', 200 | reportsSchedules: { 201 | name: 'reports', 202 | tab: 'schedules', 203 | }, 204 | reportDetails: { 205 | name: 'reports.details', 206 | requires: 'id', 207 | }, 208 | reportsScheduleDetails: { 209 | name: 'reports.schedules.details', 210 | requires: 'id', 211 | }, 212 | 213 | reportTemplates: 'settings.reports', 214 | reportTemplateDetails: { 215 | name: 'settings.reports.template.details', 216 | requires: 'id', 217 | }, 218 | 219 | billingStreams: 'commerce.billing.streams', 220 | billingStreamDetails: { 221 | name: 'commerce.billing.streams.details', 222 | requires: 'id', 223 | }, 224 | billingBatches: 'commerce.billing.batches', 225 | billingBatchDetails: { 226 | name: 'commerce.billing.batches.details', 227 | requires: 'id', 228 | }, 229 | billingRequests: 'commerce.billing.requests', 230 | billingRequestDetails: { 231 | name: 'commerce.billing.requests.details', 232 | requires: 'id', 233 | }, 234 | 235 | pricingStreams: 'commerce.pricing.streams', 236 | pricingStreamDetails: { 237 | name: 'commerce.pricing.streams.details', 238 | requires: 'id', 239 | }, 240 | pricingBatches: 'commerce.pricing.batches', 241 | pricingBatchDetails: { 242 | name: 'commerce.pricing.batches.details', 243 | requires: 'id', 244 | }, 245 | pricingRequests: 'commerce.pricing.requests', 246 | pricingRequestDetails: { 247 | name: 'commerce.pricing.requests.details', 248 | requires: 'id', 249 | }, 250 | 251 | partners: 'partners', 252 | partnerDetails: { 253 | name: 'partners.details', 254 | requires: 'id', 255 | }, 256 | partnersForms: 'partners.forms', 257 | agreements: 'partners.agreements', 258 | agreementDetails: { 259 | name: 'partners.agreements.details', 260 | requires: 'id', 261 | }, 262 | contracts: 'partners.contracts', 263 | contractDetails: { 264 | name: 'partners.contracts.details', 265 | requires: 'id', 266 | }, 267 | }; 268 | 269 | export const connectPortalRoutesDict = Object.freeze( 270 | Object.keys(routes).reduce((acc, curr) => { 271 | acc[curr] = Symbol(curr); 272 | 273 | return acc; 274 | }, {}), 275 | ); 276 | 277 | // Transform all route keys to Symbol and freeze resulting object 278 | export const connectPortalRoutes = Object.freeze( 279 | Object.entries(routes).reduce((acc, [key, value]) => { 280 | acc[connectPortalRoutesDict[key]] = value; 281 | 282 | return acc; 283 | }, {}), 284 | ); 285 | -------------------------------------------------------------------------------- /components/src/core/eventBus.js: -------------------------------------------------------------------------------- 1 | import mitt from 'mitt'; 2 | 3 | const bus = mitt(); 4 | 5 | export const busMixin = { 6 | computed: { 7 | $bus() { 8 | return bus; 9 | }, 10 | }, 11 | }; 12 | 13 | export default bus; 14 | -------------------------------------------------------------------------------- /components/src/core/helpers.js: -------------------------------------------------------------------------------- 1 | export const clone = (v) => JSON.parse(JSON.stringify(v)); 2 | 3 | export const has = (prop, obj) => Object.keys(obj).includes(prop); 4 | 5 | export const path = ([h, ...t], o) => 6 | h === undefined || typeof o !== 'object' ? o : path(t, o[h]); 7 | 8 | export const call = (fn, ...args) => fn(...args); 9 | -------------------------------------------------------------------------------- /components/src/core/helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { clone, has, path } from './helpers'; 2 | 3 | describe('clone', () => { 4 | it('should create top-level copy of an object', () => { 5 | const obj = { prop: 'Lorem ipsum' }; 6 | const trg = clone(obj); 7 | 8 | trg.prop = 'CHANGED'; 9 | 10 | expect(obj.prop).toBe('Lorem ipsum'); 11 | expect(trg.prop).toBe('CHANGED'); 12 | }); 13 | }); 14 | 15 | describe('has', () => { 16 | it('returns true if the object has the property', () => { 17 | expect(has('prop', { prop: undefined })).toBeTruthy(); 18 | }); 19 | 20 | it('returns false if the object does not have the property', () => { 21 | expect(has('prop1', { prop: undefined })).toBeFalsy(); 22 | }); 23 | }); 24 | 25 | describe('path', () => { 26 | it('should return value of given path', () => { 27 | expect(path(['a', 'b', 'c'], { a: { b: { c: 'ABC' } } })).toBe('ABC'); 28 | }); 29 | 30 | it('should return value of given path even for arrays', () => { 31 | expect(path(['a', 0, 'c'], { a: [{ c: 'ABC' }] })).toBe('ABC'); 32 | }); 33 | 34 | it('should return undefined for unexisting path', () => { 35 | expect(path(['a', 'b', 'c'], { a: { c: { b: 'ABC' } } })).toBeUndefined(); 36 | }); 37 | 38 | it('should return self for not an object', () => { 39 | // (Yes it`s weird. Don`t use it this way) 40 | expect(path(['a', 'b', 'c'], 'ABC')).toBe('ABC'); 41 | }); 42 | 43 | it('should return self if some nested path resolves into not an object', () => { 44 | // (This explains previous part a little bit - A little less strict as ramda once) 45 | expect(path(['a', 'b', 0], { a: { b: 'ABC' } })).toBe('ABC'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /components/src/core/injector/core/Core.js: -------------------------------------------------------------------------------- 1 | import { call, has } from '~core/helpers'; 2 | 3 | export default class { 4 | constructor() { 5 | this.id = null; 6 | this.state = {}; 7 | this.listeners = {}; 8 | this.watchers = {}; 9 | } 10 | 11 | assign(data) { 12 | if (!this.state || !data) return; 13 | 14 | Object.keys(data).forEach((k) => { 15 | if (has(k, this.state)) this.state[k] = data[k]; 16 | if (has(k, this.watchers)) this.watchers[k].forEach(call); 17 | }); 18 | 19 | if (has('*', this.watchers)) this.watchers['*'].forEach(call); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /components/src/core/injector/core/Core.spec.js: -------------------------------------------------------------------------------- 1 | import Core from './Core'; 2 | 3 | describe('Core', () => { 4 | let core; 5 | 6 | beforeEach(() => { 7 | core = new Core(); 8 | }); 9 | 10 | describe('initial sets', () => { 11 | it.each([ 12 | ['id', null], 13 | ['state', {}], 14 | ['listeners', {}], 15 | ['watchers', {}], 16 | ])('should set %j to %j', (key, val) => { 17 | expect(core[key]).toEqual(val); 18 | }); 19 | }); 20 | 21 | describe('#assign()', () => { 22 | it.each([ 23 | [null, null], 24 | [{ test: 123 }, null], 25 | [null, { test: 123 }], 26 | ])('should do nothing while $state is %j and data is %j', (state, data) => { 27 | global.Object.keys = vi.fn(Object.keys); 28 | core.state = state; 29 | core.assign(data); 30 | 31 | expect(Object.keys).not.toHaveBeenCalledWith(data); 32 | }); 33 | 34 | it('should assign passed data to a state', () => { 35 | core.state = { 36 | test: 'TEST', 37 | }; 38 | 39 | core.assign({ 40 | test: 'NEW_TEST', 41 | foo: 'bar', 42 | }); 43 | 44 | expect(core.state).toEqual({ 45 | test: 'NEW_TEST', 46 | }); 47 | }); 48 | 49 | it('should launch proper watchers', () => { 50 | core.state = { 51 | test: 'TEST', 52 | foo: 'BAR', 53 | }; 54 | 55 | core.watchers = { 56 | test: [vi.fn()], 57 | }; 58 | 59 | core.assign({ 60 | test: 'NEW_TEST', 61 | foo: 'bar', 62 | }); 63 | 64 | expect(core.watchers.test[0]).toHaveBeenCalled(); 65 | }); 66 | 67 | it('should launch all watchers with "*" key', () => { 68 | core.state = { 69 | test: 'TEST', 70 | foo: 'BAR', 71 | }; 72 | 73 | core.watchers = { 74 | '*': [vi.fn()], 75 | }; 76 | 77 | core.assign({ 78 | test: 'NEW_TEST', 79 | foo: 'bar', 80 | }); 81 | 82 | expect(core.watchers['*'][0]).toHaveBeenCalled(); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /components/src/core/injector/core/injectorFactory.js: -------------------------------------------------------------------------------- 1 | import { clone, has } from '~core/helpers'; 2 | 3 | import { processRoute } from '~core/router'; 4 | 5 | export default (core) => { 6 | const injector = { 7 | watch(a, b, { immediate } = {}) { 8 | let fn, name; 9 | 10 | if (typeof a === 'function') { 11 | name = '*'; 12 | fn = a; 13 | } else { 14 | name = a; 15 | fn = b; 16 | } 17 | 18 | if (!has(name, core.watchers)) core.watchers[name] = []; 19 | 20 | core.watchers[name].push(() => { 21 | fn(name === '*' ? core.state : core.state[name]); 22 | }); 23 | 24 | if (immediate) { 25 | fn(name === '*' ? core.state : core.state[name]); 26 | } 27 | }, 28 | 29 | commit(data) { 30 | core.assign(data); 31 | 32 | window.top.postMessage( 33 | { 34 | $id: core.id || null, 35 | data: core.state ? clone(core.state) : null, 36 | }, 37 | '*', 38 | ); 39 | }, 40 | 41 | emit(name, data = true) { 42 | window.top.postMessage( 43 | { 44 | $id: core.id || null, 45 | events: { 46 | [name]: data, 47 | }, 48 | }, 49 | '*', 50 | ); 51 | }, 52 | 53 | listen(name, cb) { 54 | core.listeners[name] = cb; 55 | }, 56 | 57 | navigateTo(route, param) { 58 | injector.emit('navigate-to', processRoute(route, param)); 59 | }, 60 | }; 61 | 62 | return injector; 63 | }; 64 | -------------------------------------------------------------------------------- /components/src/core/injector/core/injectorFactory.spec.js: -------------------------------------------------------------------------------- 1 | import injectorFactory from './injectorFactory'; 2 | import { processRoute } from '~core/router'; 3 | 4 | vi.mock('~core/router', () => ({ 5 | processRoute: vi.fn().mockReturnValue('processRouteMockedReturnValue'), 6 | })); 7 | 8 | describe('injectorFactory', () => { 9 | describe('#watch()', () => { 10 | it('should add callback as new watcher for passed property', () => { 11 | const core = { watchers: {}, state: { foo: 'bar' } }; 12 | const injector = injectorFactory(core); 13 | const cb = vi.fn(); 14 | 15 | injector.watch('foo', cb); 16 | core.watchers.foo[0](); 17 | 18 | expect(cb).toHaveBeenCalledWith('bar'); 19 | }); 20 | 21 | it('should add immediate callback call', () => { 22 | const core = { watchers: {}, state: { foo: 'bar' } }; 23 | const injector = injectorFactory(core); 24 | const cb = vi.fn(); 25 | 26 | injector.watch('foo', cb, { immediate: true }); 27 | 28 | expect(cb).toHaveBeenCalledWith('bar'); 29 | }); 30 | 31 | it('should add immediate callback call with "*" key', () => { 32 | const core = { watchers: {}, state: { foo: 'bar' } }; 33 | const injector = injectorFactory(core); 34 | const cb = vi.fn(); 35 | 36 | injector.watch('*', cb, { immediate: true }); 37 | 38 | expect(cb).toHaveBeenCalledWith({ foo: 'bar' }); 39 | }); 40 | 41 | it('should extend existing watcher for passed property with a callback', () => { 42 | const core = { 43 | watchers: { foo: [() => {}] }, 44 | state: { foo: 'bar' }, 45 | }; 46 | 47 | const injector = injectorFactory(core); 48 | const cb = vi.fn(); 49 | 50 | injector.watch('foo', cb); 51 | core.watchers.foo[1](); 52 | 53 | expect(cb).toHaveBeenCalledWith('bar'); 54 | }); 55 | 56 | it('should add a callback as watcher for whole state if just callback passed', () => { 57 | const core = { 58 | watchers: {}, 59 | state: { foo: 'bar', bar: 'foo' }, 60 | }; 61 | 62 | const injector = injectorFactory(core); 63 | const cb = vi.fn(); 64 | 65 | injector.watch(cb); 66 | core.watchers['*'][0](); 67 | 68 | expect(cb).toHaveBeenCalledWith({ foo: 'bar', bar: 'foo' }); 69 | }); 70 | }); 71 | 72 | describe('#commit()', () => { 73 | let core; 74 | let commit; 75 | 76 | beforeEach(() => { 77 | global.window.top.postMessage = vi.fn(); 78 | 79 | core = { 80 | assign: vi.fn(), 81 | id: 'XXX', 82 | state: { 83 | foo: 'bar', 84 | }, 85 | }; 86 | 87 | commit = (data) => { 88 | injectorFactory(core).commit(data); 89 | }; 90 | }); 91 | 92 | it('should call $assign() with passed data', () => { 93 | commit({ foo: 'BAR' }); 94 | 95 | expect(core.assign).toHaveBeenCalledWith({ foo: 'BAR' }); 96 | }); 97 | 98 | it('should call window.top.postMessage() with proper id and data', () => { 99 | commit({ foo: 'BAR' }); 100 | 101 | expect(window.top.postMessage).toHaveBeenCalledWith( 102 | { 103 | $id: 'XXX', 104 | data: { foo: 'bar' }, 105 | }, 106 | '*', 107 | ); 108 | }); 109 | 110 | it('should set null by default for passed state props', () => { 111 | core.id = undefined; 112 | core.state = undefined; 113 | commit({ foo: 'BAR' }); 114 | 115 | expect(window.top.postMessage).toHaveBeenCalledWith( 116 | { 117 | $id: null, 118 | data: null, 119 | }, 120 | '*', 121 | ); 122 | }); 123 | }); 124 | 125 | describe('#emit()', () => { 126 | beforeEach(() => { 127 | global.window.top.postMessage = vi.fn(); 128 | }); 129 | 130 | it('should emit proper event', () => { 131 | injectorFactory({ id: 'XXX' }).emit('foo', 'BAR'); 132 | 133 | expect(window.top.postMessage).toHaveBeenCalledWith( 134 | { 135 | $id: 'XXX', 136 | events: { 137 | foo: 'BAR', 138 | }, 139 | }, 140 | '*', 141 | ); 142 | }); 143 | 144 | it('should set $id to null when not passed', () => { 145 | injectorFactory({}).emit('foo', 'BAR'); 146 | 147 | expect(window.top.postMessage).toHaveBeenCalledWith( 148 | { 149 | $id: null, 150 | events: { 151 | foo: 'BAR', 152 | }, 153 | }, 154 | '*', 155 | ); 156 | }); 157 | 158 | it('should set data to true by default', () => { 159 | injectorFactory({ id: 'XXX' }).emit('foo'); 160 | 161 | expect(window.top.postMessage).toHaveBeenCalledWith( 162 | { 163 | $id: 'XXX', 164 | events: { 165 | foo: true, 166 | }, 167 | }, 168 | '*', 169 | ); 170 | }); 171 | }); 172 | 173 | describe('#listen()', () => { 174 | it('should put provided callback to proper listeners', () => { 175 | const core = { listeners: {} }; 176 | const cb = vi.fn(); 177 | injectorFactory(core).listen('foo', cb); 178 | core.listeners.foo(); 179 | 180 | expect(cb).toHaveBeenCalled(); 181 | }); 182 | }); 183 | 184 | describe('#navigateTo', () => { 185 | let injector; 186 | let injectorEmitSpy; 187 | 188 | beforeEach(() => { 189 | injector = injectorFactory({}); 190 | injectorEmitSpy = vi.spyOn(injector, 'emit'); 191 | }); 192 | 193 | it('calls processRoute with the given arguments', () => { 194 | injector.navigateTo('foo', 'bar'); 195 | 196 | expect(processRoute).toHaveBeenCalledWith('foo', 'bar'); 197 | }); 198 | 199 | it('calls injector.emit with the result of calling processRoute', () => { 200 | injector.navigateTo('foo', 'bar'); 201 | 202 | expect(injectorEmitSpy).toHaveBeenCalledWith('navigate-to', 'processRouteMockedReturnValue'); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /components/src/core/injector/core/launcher.js: -------------------------------------------------------------------------------- 1 | const getId = () => 2 | ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => 3 | (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), 4 | ); 5 | 6 | export default (injector, core, options = {}) => 7 | new Promise((resolve) => { 8 | core.id = window.name || `slot_${getId()}`; 9 | injector.emit('$created'); 10 | 11 | injector.listen('$init', (data) => { 12 | core.state = data; 13 | 14 | if (!options?.disableAutoResizing) { 15 | const resizeObserver = new ResizeObserver((entries) => { 16 | const [{ contentRect }] = entries; 17 | injector.emit('$size', { height: contentRect.height, width: contentRect.width }); 18 | }); 19 | 20 | resizeObserver.observe(document.body); 21 | } 22 | 23 | resolve(); 24 | }); 25 | 26 | window.addEventListener('$injector', ({ detail }) => { 27 | let { type, data } = detail; 28 | 29 | if (type === '$size') { 30 | const { height, width } = document.body.getBoundingClientRect(); 31 | data = { height, width }; 32 | } 33 | 34 | injector.emit(type, data); 35 | }); 36 | 37 | window.addEventListener('message', ({ data: $data }) => { 38 | if ($data?.$id !== core.id) return; 39 | const { data, events } = $data; 40 | 41 | if (events) { 42 | Object.keys(events).forEach((event) => { 43 | if (core.listeners[event]) core.listeners[event](events[event], $data); 44 | }); 45 | } else if (data) { 46 | core.assign(data); 47 | } 48 | }); 49 | 50 | injector.emit('$mounted'); 51 | }); 52 | -------------------------------------------------------------------------------- /components/src/core/injector/core/launcher.spec.js: -------------------------------------------------------------------------------- 1 | import launch from './launcher'; 2 | 3 | const resizeObserverCtorSpy = vi.fn(); 4 | const resizeObserverObserveSpy = vi.fn(); 5 | const resizeObserverEntriesStub = [ 6 | { 7 | contentRect: { 8 | height: 400, 9 | width: 800, 10 | }, 11 | }, 12 | ]; 13 | const ResizeObserverMock = function ResizeObserverMock(callback) { 14 | resizeObserverCtorSpy(callback); 15 | 16 | callback(resizeObserverEntriesStub); 17 | 18 | return { 19 | observe: resizeObserverObserveSpy, 20 | }; 21 | }; 22 | Object.defineProperty(global, 'ResizeObserver', { 23 | writable: true, 24 | value: ResizeObserverMock, 25 | }); 26 | 27 | describe('$init', () => { 28 | let injector; 29 | let core; 30 | let init; 31 | 32 | beforeEach(() => { 33 | global.window.addEventListener = vi.fn(); 34 | global.window.name = 'XXX'; 35 | global.crypto.getRandomValues = vi.fn(() => ['abc']); 36 | 37 | injector = { 38 | listen: vi.fn(), 39 | emit: vi.fn(), 40 | }; 41 | 42 | core = { 43 | size: vi.fn(() => 'SIZE'), 44 | assign: vi.fn(), 45 | }; 46 | 47 | init = () => launch(injector, core); 48 | }); 49 | 50 | describe('places listener for "$init" event', () => { 51 | let name; 52 | let handler; 53 | 54 | beforeEach(() => { 55 | init(); 56 | name = injector.listen.mock.calls[0][0]; 57 | handler = injector.listen.mock.calls[0][1]; 58 | }); 59 | 60 | it('should set listener for proper event name', () => { 61 | expect(name).toEqual('$init'); 62 | }); 63 | 64 | it('handler should set proper id to state', () => { 65 | handler({}, { $id: 'XXX' }); 66 | 67 | expect(core.id).toBe('XXX'); 68 | }); 69 | 70 | it('should handler should set passed data to state', () => { 71 | handler({ foo: 'BAR' }, {}); 72 | 73 | expect(core.state).toEqual({ foo: 'BAR' }); 74 | }); 75 | 76 | it('should emit "$size" event', () => { 77 | handler({}, {}); 78 | 79 | expect(injector.emit.mock.calls[2]).toEqual([ 80 | '$size', 81 | { 82 | height: 400, 83 | width: 800, 84 | }, 85 | ]); 86 | }); 87 | 88 | it('should create a ResizeObserver instance', () => { 89 | handler({}, {}); 90 | 91 | expect(resizeObserverCtorSpy).toHaveBeenCalledWith(expect.any(Function)); 92 | }); 93 | 94 | it("should call the resize observer's observe method with the document body", () => { 95 | handler({}, {}); 96 | 97 | expect(resizeObserverObserveSpy).toHaveBeenCalledWith(document.body); 98 | }); 99 | }); 100 | 101 | describe('places a listener for "$injector"', () => { 102 | let name; 103 | let handler; 104 | 105 | beforeEach(() => { 106 | init(); 107 | name = window.addEventListener.mock.calls[0][0]; 108 | handler = window.addEventListener.mock.calls[0][1]; 109 | }); 110 | 111 | it('should set event listener with proper name', () => { 112 | expect(name).toBe('$injector'); 113 | }); 114 | 115 | it('should emit proper listener event', () => { 116 | handler({ detail: { type: 'event_name', data: { foo: 'bar' } } }); 117 | 118 | expect(injector.emit.mock.calls[2]).toEqual(['event_name', { foo: 'bar' }]); 119 | }); 120 | 121 | it('should fill size for $size type', () => { 122 | handler({ detail: { type: '$size' } }); 123 | 124 | expect(injector.emit.mock.calls[2]).toEqual([ 125 | '$size', 126 | { 127 | height: 0, 128 | width: 0, 129 | }, 130 | ]); 131 | }); 132 | }); 133 | 134 | describe('places a listener for "message"', () => { 135 | let name; 136 | let handler; 137 | 138 | beforeEach(() => { 139 | init(); 140 | name = window.addEventListener.mock.calls[1][0]; 141 | handler = window.addEventListener.mock.calls[1][1]; 142 | core.id = 'XXX'; 143 | }); 144 | 145 | it('should place a correct listener name', () => { 146 | expect(name).toBe('message'); 147 | }); 148 | 149 | it('should early return when no $id provided', () => { 150 | handler({ data: {} }); 151 | 152 | expect(core.assign).not.toHaveBeenCalled(); 153 | }); 154 | 155 | it('should launch proper listeners when handling events', () => { 156 | const data = { 157 | $id: 'XXX', 158 | events: { 159 | test: 'TEST', 160 | foo: 'bar', 161 | }, 162 | }; 163 | 164 | core.listeners = { test: vi.fn() }; 165 | handler({ data }); 166 | 167 | expect(core.listeners.test).toHaveBeenCalledWith('TEST', data); 168 | }); 169 | 170 | it('should call state.$assign with proper data when id is correct', () => { 171 | handler({ 172 | data: { 173 | $id: 'XXX', 174 | data: { foo: 'bar' }, 175 | }, 176 | }); 177 | 178 | expect(core.assign).toHaveBeenCalledWith({ foo: 'bar' }); 179 | }); 180 | 181 | it('should do nothing with processing data if id is not correct', () => { 182 | handler({ 183 | data: { 184 | $id: 'YYY', 185 | data: { foo: 'bar' }, 186 | }, 187 | }); 188 | 189 | expect(core.assign).not.toHaveBeenCalled(); 190 | }); 191 | 192 | it('should emit $mounted event', () => { 193 | expect(injector.emit).toHaveBeenCalledWith('$mounted'); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /components/src/core/injector/index.js: -------------------------------------------------------------------------------- 1 | import Core from './core/Core'; 2 | import injectorFactory from './core/injectorFactory'; 3 | import launch from './core/launcher'; 4 | 5 | export const injectorMixin = { 6 | methods: { 7 | $injector(type, data) { 8 | window.dispatchEvent(new CustomEvent('$injector', { detail: { type, data } })); 9 | }, 10 | }, 11 | }; 12 | 13 | export default async (options = {}) => { 14 | const core = new Core(); 15 | const injector = injectorFactory(core); 16 | await launch(injector, core, options); 17 | 18 | return injector; 19 | }; 20 | -------------------------------------------------------------------------------- /components/src/core/injector/index.spec.js: -------------------------------------------------------------------------------- 1 | import createInjector from '.'; 2 | 3 | import Core from './core/Core'; 4 | import injectorFactory from './core/injectorFactory'; 5 | import launcher from './core/launcher'; 6 | 7 | vi.mock('./core/Core', () => ({ 8 | __esModule: true, 9 | default: vi.fn().mockImplementation(() => ({ core: 'CORE' })), 10 | })); 11 | 12 | vi.mock('./core/injectorFactory', () => ({ 13 | __esModule: true, 14 | default: vi.fn(() => 'INJECTOR'), 15 | })); 16 | 17 | vi.mock('./core/launcher', () => ({ 18 | __esModule: true, 19 | default: vi.fn(() => Promise.resolve()), 20 | })); 21 | 22 | describe('createInjector', () => { 23 | beforeEach(() => { 24 | createInjector(); 25 | }); 26 | 27 | it('should create a Core', () => { 28 | expect(Core).toHaveBeenCalled(); 29 | }); 30 | 31 | it('should create an injector', () => { 32 | expect(injectorFactory).toHaveBeenCalledWith({ core: 'CORE' }); 33 | }); 34 | 35 | it('should launch an injector', () => { 36 | expect(launcher).toHaveBeenCalledWith('INJECTOR', { core: 'CORE' }, {}); 37 | }); 38 | }); 39 | 40 | describe('createInjector on launcher error', () => { 41 | beforeEach(() => { 42 | launcher.mockImplementation(() => Promise.reject(new Error('ERROR'))); 43 | }); 44 | 45 | it('should throw an error', async () => { 46 | let error; 47 | 48 | try { 49 | await createInjector(); 50 | } catch (e) { 51 | error = e; 52 | } 53 | 54 | expect(error).toBeInstanceOf(Error); 55 | expect(error.message).toEqual('ERROR'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /components/src/core/registerWidget.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement } from 'vue'; 2 | 3 | import { busMixin } from '~core/eventBus'; 4 | 5 | import { storeMixin } from '~core/store'; 6 | 7 | import { injectorMixin } from '~core/injector'; 8 | 9 | export default (name, component) => { 10 | if (!component.mixins) component.mixins = []; 11 | component.mixins.push(injectorMixin, busMixin, storeMixin); 12 | 13 | if (!customElements.get(name)) customElements.define(name, defineCustomElement(component)); 14 | }; 15 | -------------------------------------------------------------------------------- /components/src/core/router.js: -------------------------------------------------------------------------------- 1 | import { connectPortalRoutes, connectPortalRoutesDict } from '~constants/portal-routes'; 2 | 3 | const processRegisteredRoute = (route, param) => { 4 | const spaRoute = connectPortalRoutes[route]; 5 | let processedRoute = { name: '' }; 6 | 7 | if (!spaRoute) { 8 | throw new Error( 9 | `[Connect UI Toolkit]: Route ${route.toString()} does not exist.\nThe following routes are available:\n${Object.keys(connectPortalRoutesDict).join(', ')}`, 10 | ); 11 | } 12 | 13 | if (typeof spaRoute === 'string') { 14 | processedRoute.name = spaRoute; 15 | } else { 16 | processedRoute.name = spaRoute.name; 17 | processedRoute.params = {}; 18 | 19 | if (spaRoute.tab) { 20 | processedRoute.params.tab = spaRoute.tab; 21 | } 22 | 23 | if (spaRoute.requires) { 24 | if (!param) { 25 | throw new Error( 26 | `[Connect UI Toolkit]: Route ${route.toString()} requires the ${spaRoute.requires} parameter.`, 27 | ); 28 | } 29 | 30 | processedRoute.params[spaRoute.requires] = param; 31 | } 32 | } 33 | 34 | return processedRoute; 35 | }; 36 | 37 | export const processRoute = (route, param) => { 38 | if (!route) { 39 | throw new Error('[Connect UI Toolkit]: Empty route cannot be processed.'); 40 | } 41 | 42 | // If route is an object or a string, avoid processing 43 | if (['object', 'string'].includes(typeof route)) { 44 | return route; 45 | } 46 | 47 | // If route is symbol, process it according to the registered spa routes 48 | if (typeof route === 'symbol') { 49 | return processRegisteredRoute(route, param); 50 | } 51 | 52 | throw new Error( 53 | `[Connect UI Toolkit]: Route could not be processed. Route is: ${JSON.stringify(route)}`, 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /components/src/core/router.spec.js: -------------------------------------------------------------------------------- 1 | import { connectPortalRoutes, connectPortalRoutesDict } from '~constants/portal-routes'; 2 | 3 | import { processRoute } from '~core/router'; 4 | 5 | describe('#processRoute', () => { 6 | let result; 7 | let err; 8 | 9 | beforeEach(() => { 10 | result = undefined; 11 | err = undefined; 12 | }); 13 | 14 | describe('if route is not used', () => { 15 | it('throws an error', () => { 16 | try { 17 | processRoute(); 18 | } catch (e) { 19 | err = e; 20 | } 21 | 22 | expect(err).toBeInstanceOf(Error); 23 | expect(err.message).toEqual('[Connect UI Toolkit]: Empty route cannot be processed.'); 24 | }); 25 | }); 26 | 27 | describe('if route is a String', () => { 28 | it('returns the route without processing', () => { 29 | result = processRoute('foo'); 30 | 31 | expect(result).toEqual('foo'); 32 | }); 33 | }); 34 | 35 | describe('if route is an Object', () => { 36 | it('returns the route without processing', () => { 37 | result = processRoute({ foo: 'bar' }); 38 | 39 | expect(result).toEqual({ foo: 'bar' }); 40 | }); 41 | }); 42 | 43 | describe('if route is a Symbol', () => { 44 | it('throws an error if the route is not part of the connect portal routes', () => { 45 | const fakeRoute = Symbol('foo'); 46 | 47 | try { 48 | processRoute(fakeRoute); 49 | } catch (e) { 50 | err = e; 51 | } 52 | 53 | expect(err).toBeInstanceOf(Error); 54 | expect(err.message).toEqual( 55 | `[Connect UI Toolkit]: Route ${fakeRoute.toString()} does not exist.\nThe following routes are available:\n${Object.keys(connectPortalRoutesDict).join(', ')}`, 56 | ); 57 | }); 58 | 59 | it('returns the correct route for a simple route', () => { 60 | const simpleRoute = connectPortalRoutesDict.dashboard; 61 | 62 | result = processRoute(simpleRoute); 63 | 64 | expect(result).toEqual({ name: connectPortalRoutes[simpleRoute] }); 65 | }); 66 | 67 | it('returns the correct route for a route that has a tab', () => { 68 | const routeWithTab = connectPortalRoutesDict.fulfillmentRequests; 69 | 70 | result = processRoute(routeWithTab); 71 | 72 | expect(result).toEqual({ 73 | name: connectPortalRoutes[routeWithTab].name, 74 | params: { 75 | tab: connectPortalRoutes[routeWithTab].tab, 76 | }, 77 | }); 78 | }); 79 | 80 | it('throws an error if the route requires a parameter that is not sent', () => { 81 | const routeWithRequiredParameter = connectPortalRoutesDict.marketplaceDetails; 82 | 83 | try { 84 | processRoute(routeWithRequiredParameter); 85 | } catch (e) { 86 | err = e; 87 | } 88 | 89 | expect(err).toBeInstanceOf(Error); 90 | expect(err.message).toEqual( 91 | `[Connect UI Toolkit]: Route ${routeWithRequiredParameter.toString()} requires the ${connectPortalRoutes[routeWithRequiredParameter].requires} parameter.`, 92 | ); 93 | }); 94 | 95 | it('returns the correct route for a route that requires a parameter and it is sent', () => { 96 | const routeWithRequiredParameter = connectPortalRoutesDict.marketplaceDetails; 97 | 98 | result = processRoute(routeWithRequiredParameter, 'MKP-123'); 99 | 100 | expect(result).toEqual({ 101 | name: connectPortalRoutes[routeWithRequiredParameter].name, 102 | params: { 103 | [connectPortalRoutes[routeWithRequiredParameter].requires]: 'MKP-123', 104 | }, 105 | }); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /components/src/core/store.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | 3 | import { has, path } from '~core/helpers'; 4 | 5 | export const $module = ({ state = {}, actions = {} }) => { 6 | return { 7 | state: reactive(state), 8 | actions, 9 | }; 10 | }; 11 | 12 | export const createStore = () => { 13 | /* No direct access to modules from outside! */ 14 | const $modules = {}; 15 | const $subscribers = {}; 16 | 17 | return { 18 | /* Required to add new module to a bus */ 19 | add(module) { 20 | if (!module.name) throw new Error('Module must have a "name" property'); 21 | 22 | $modules[module.name] = $module(module); 23 | }, 24 | 25 | /* Reactivity is granted by state itself - so it is just a simple getter on this level */ 26 | /* Using "*" for property name will return whole module state at once */ 27 | watch(module, property = '*') { 28 | if (property === '*') return $modules[module].state; 29 | 30 | return $modules[module].state[property]; 31 | }, 32 | 33 | /* Is basically a mutation - a setter for state property */ 34 | /* As state reactivity is proxy-based it is sensitive for immutable ways to set */ 35 | /* This is why instead of direct assignment in components is better to use this mutation */ 36 | commit(module, property, value) { 37 | $modules[module].state[property] = value; 38 | (path([module, property], $subscribers) || []).forEach((cb) => cb(value)); 39 | }, 40 | 41 | /* This one is for triggering actions */ 42 | /* NB: I bind here module scope as a context - to allow some flexibility */ 43 | /* However, if it won't be safe enough we may want to change it by passing 44 | /* 'commit' and 'dispatch' interface as it is done in vuex */ 45 | dispatch(module, action, data) { 46 | return $modules[module].actions[action].call($modules[module], data, $modules); 47 | }, 48 | 49 | /* For those who don't have vue reactivity */ 50 | listen(module, prop, cb) { 51 | if (!has(module, $subscribers)) $subscribers[module] = {}; 52 | if (!has(prop, $subscribers[module])) $subscribers[module][prop] = []; 53 | 54 | $subscribers[module][prop].push(cb); 55 | }, 56 | }; 57 | }; 58 | 59 | const store = createStore(); 60 | 61 | export const storeMixin = { 62 | computed: { 63 | $store() { 64 | return store; 65 | }, 66 | }, 67 | }; 68 | 69 | export default store; 70 | -------------------------------------------------------------------------------- /components/src/index.js: -------------------------------------------------------------------------------- 1 | import $injector from '~core/injector'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | export { default as Tabs } from '~widgets/tabs/widget.vue'; 5 | export { default as Tab } from '~widgets/tab/widget.vue'; 6 | export { default as Pad } from '~widgets/pad/widget.vue'; 7 | export { default as Card } from '~widgets/card/widget.vue'; 8 | export { default as Icon } from '~widgets/icon/widget.vue'; 9 | export { default as View } from '~widgets/view/widget.vue'; 10 | export { default as Navigation } from '~widgets/navigation/widget.vue'; 11 | export { default as Status } from '~widgets/status/widget.vue'; 12 | export { default as Select } from '~widgets/select/widget.vue'; 13 | export { default as Textfield } from '~widgets/textfield/widget.vue'; 14 | export { default as Table } from '~widgets/table/widget.vue'; 15 | export { default as ComplexTable } from './widgets/complexTable/widget.vue'; 16 | export { default as Button } from '~widgets/button/widget.vue'; 17 | export { default as Menu } from '~widgets/menu/widget.vue'; 18 | export { default as Textarea } from '~widgets/textarea/widget.vue'; 19 | export { default as Alert } from '~widgets/alert/widget.vue'; 20 | export { default as Radio } from '~widgets/radio/widget.vue'; 21 | export { default as Dialog } from '~widgets/dialog/widget.vue'; 22 | export { default as Autocomplete } from '~widgets/autocomplete/widget.vue'; 23 | 24 | export { default as store } from '~core/store'; 25 | export { default as bus } from '~core/eventBus'; 26 | 27 | export { connectPortalRoutesDict as connectPortalRoutes } from '~constants/portal-routes'; 28 | 29 | export default (widgets = {}, options = {}) => { 30 | for (const widget in widgets) registerWidget(widget, widgets[widget]); 31 | 32 | return $injector(options); 33 | }; 34 | -------------------------------------------------------------------------------- /components/src/index.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/one-component-per-file */ 2 | import createApp from './index'; 3 | import injector from '~core/injector'; 4 | import registerWidget from '~core/registerWidget'; 5 | 6 | vi.mock('~core/injector', () => ({ 7 | __esModule: true, 8 | default: vi.fn(() => 'app'), 9 | })); 10 | 11 | vi.mock('~core/registerWidget', () => ({ 12 | __esModule: true, 13 | default: vi.fn(), 14 | })); 15 | 16 | describe('#createApp function', () => { 17 | it('calls registerWidget with every widget passed', () => { 18 | createApp({ foo: 'Foo' }); 19 | 20 | expect(registerWidget).toHaveBeenCalledWith('foo', 'Foo'); 21 | }); 22 | 23 | it('calls the injector with the options', () => { 24 | createApp({}, { foo: 'bar' }); 25 | 26 | expect(injector).toHaveBeenCalledWith({ foo: 'bar' }); 27 | }); 28 | 29 | it('returns the result of the injector call', () => { 30 | const result = createApp(); 31 | 32 | expect(result).toEqual('app'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /components/src/stories/Alert.stories.js: -------------------------------------------------------------------------------- 1 | import Alert from '~widgets/alert/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-alert', Alert); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: ``, 12 | }), 13 | 14 | args: { 15 | message: 'This is an alert item', 16 | }, 17 | }; 18 | 19 | export default { 20 | title: 'Components/Alert', 21 | component: Alert, 22 | parameters: { 23 | layout: 'centered', 24 | }, 25 | argTypes: { 26 | message: 'text', 27 | icon: 'text', 28 | type: 'text', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /components/src/stories/Autocomplete.stories.js: -------------------------------------------------------------------------------- 1 | import Autocomplete from '~widgets/autocomplete/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-autocomplete', Autocomplete); 5 | 6 | export const Basic = { 7 | name: 'Basic options', 8 | render: (args) => ({ 9 | setup() { 10 | return { args }; 11 | }, 12 | template: '', 13 | }), 14 | 15 | args: { 16 | label: 'Label text', 17 | options: ['Andorra', 'Peru', 'Poland', 'Spain', 'USA'], 18 | }, 19 | }; 20 | 21 | export const Object = { 22 | name: 'Array of objects in options', 23 | render: Basic.render, 24 | args: { 25 | ...Basic.args, 26 | propValue: 'value', 27 | propText: 'label', 28 | options: [ 29 | { value: 'AR', label: 'Argentina' }, 30 | { value: 'AD', label: 'Andorra' }, 31 | { value: 'PL', label: 'Poland' }, 32 | ], 33 | }, 34 | }; 35 | 36 | export const Validation = { 37 | name: 'Input validation', 38 | render: Basic.render, 39 | 40 | args: { 41 | ...Basic.args, 42 | label: 'Select input with validation', 43 | hint: 'Select the second option if you want the validation to be successful', 44 | propValue: 'id', 45 | propText: 'name', 46 | required: true, 47 | options: [ 48 | { id: 'OBJ-123', name: 'The first object' }, 49 | { id: 'OBJ-456', name: 'The second object' }, 50 | { id: 'OBJ-789', name: 'The third object' }, 51 | ], 52 | rules: [(value) => value === 'OBJ-456' || 'You picked the wrong option :( '], 53 | }, 54 | }; 55 | 56 | export const ExtraProps = { 57 | name: 'Customized options text', 58 | render: Basic.render, 59 | args: { 60 | ...Basic.args, 61 | label: 'This implementation uses the "optionTextFn" and "menuProps"', 62 | options: [ 63 | { value: 'AR', label: 'Argentina' }, 64 | { value: 'AD', label: 'Andorra' }, 65 | { value: 'PL', label: 'Poland' }, 66 | ], 67 | propValue: 'value', 68 | propText: 'label', 69 | optionTextFn: (item) => `${item.label} (${item.value})`, 70 | menuProps: { fullWidth: false }, 71 | }, 72 | }; 73 | 74 | export default { 75 | title: 'Components/Autocomplete', 76 | component: Autocomplete, 77 | parameters: { 78 | layout: 'centered', 79 | }, 80 | argTypes: { 81 | label: 'text', 82 | modelValue: 'text', 83 | hint: 'text', 84 | propValue: 'text', 85 | propText: 'text', 86 | required: 'boolean', 87 | options: { control: 'array' }, 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /components/src/stories/Button.stories.js: -------------------------------------------------------------------------------- 1 | import Button from '~widgets/button/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-button', Button); 5 | 6 | export const Base = { 7 | name: 'Base component', 8 | render: (args) => ({ 9 | setup() { 10 | const showAlert = () => alert('The button was clicked'); 11 | return { args, showAlert }; 12 | }, 13 | template: '', 14 | }), 15 | args: { 16 | mode: 'solid', 17 | size: 'large', 18 | label: 'Accept', 19 | icon: 'googleCheckBaseline', 20 | iconRight: '', 21 | color: '#2C98F0', 22 | progress: false, 23 | lowerCase: false, 24 | onlyIcon: false, 25 | disabled: false, 26 | }, 27 | }; 28 | 29 | export const Slotted = { 30 | name: 'Using the default slot', 31 | render: (args) => ({ 32 | setup() { 33 | const showAlert = () => alert('The button was clicked'); 34 | return { args, showAlert }; 35 | }, 36 | template: 37 | '
Custom slot content
', 38 | }), 39 | args: { 40 | ...Base.args, 41 | label: '', 42 | }, 43 | }; 44 | 45 | export default { 46 | title: 'Components/Button', 47 | component: Button, 48 | parameters: { 49 | layout: 'centered', 50 | design: { 51 | type: 'figma', 52 | url: 'https://www.figma.com/file/iWvG1cSD2xzbGS2KAB1DgV/Connect-UI-Guides-%26-Specs?type=design&node-id=1-4009&mode=design&t=5CPLKuHbPQnKMEJh-0', 53 | }, 54 | }, 55 | argTypes: { 56 | mode: { control: 'radio', options: ['solid', 'flat', 'outlined'] }, 57 | size: { control: 'radio', options: ['small', 'large'] }, 58 | label: 'text', 59 | icon: 'text', 60 | iconRight: 'text', 61 | color: 'text', 62 | progress: 'boolean', 63 | lowerCase: 'boolean', 64 | onlyIcon: 'boolean', 65 | disabled: 'boolean', 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /components/src/stories/Card.stories.js: -------------------------------------------------------------------------------- 1 | import cCard from '~widgets/card/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-card', cCard); 5 | 6 | export default { 7 | title: 'Components/Card', 8 | component: cCard, 9 | parameters: { 10 | layout: 'centered', 11 | design: { 12 | type: 'figma', 13 | url: 'https://www.figma.com/file/iWvG1cSD2xzbGS2KAB1DgV/Connect-UI-Guides-%26-Specs?node-id=1%3A5688&t=a9arRvCkF2acPp5E-1', 14 | }, 15 | }, 16 | }; 17 | 18 | export const Component = { 19 | render: (args) => ({ 20 | setup() { 21 | return { args }; 22 | }, 23 | template: ` 24 | 25 |
26 | {{ args.content }} 27 |
28 |
`, 29 | }), 30 | 31 | args: { 32 | title: 'Card Title', 33 | subtitle: 'Card Subtitle', 34 | content: 'Card Content', 35 | }, 36 | }; 37 | 38 | export const ComponentWithAllSlots = { 39 | render: (args) => ({ 40 | setup() { 41 | return { args }; 42 | }, 43 | template: ` 44 | 45 |
46 | {{ args.content }} 47 |
48 | Custom title, link 49 |

My custom subtitle :)

50 |
51 | 52 |
53 |
`, 54 | }), 55 | 56 | args: { 57 | title: 'Card title', 58 | content: 'Card Content', 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /components/src/stories/ComplexTable.stories.js: -------------------------------------------------------------------------------- 1 | import ComplexTable from '~widgets/complexTable/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-complex-table', ComplexTable); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: ` 12 | 13 | John 14 | Doe 15 | 57 16 | 17 | 18 | Mary 19 | Stephen 20 | 26 21 | 22 | `, 23 | }), 24 | 25 | args: { 26 | headers: [ 27 | { 28 | name: 'name', 29 | width: '80px', 30 | text: 'Name', 31 | filterable: true, 32 | }, 33 | { 34 | name: 'lastname', 35 | width: '80px', 36 | text: 'LastName', 37 | filterable: true, 38 | }, 39 | { 40 | name: 'age', 41 | width: '40px', 42 | text: 'Age', 43 | }, 44 | ], 45 | items: [ 46 | { 47 | name: 'John', 48 | lastName: 'Doe', 49 | age: 33, 50 | }, 51 | { 52 | name: 'Mary', 53 | lastName: 'Stephen', 54 | age: 26, 55 | }, 56 | ], 57 | currentPage: 1, 58 | totalItems: 50, 59 | }, 60 | }; 61 | 62 | export default { 63 | title: 'Components/ComplexTable', 64 | component: ComplexTable, 65 | parameters: { 66 | layout: 'centered', 67 | }, 68 | argTypes: { 69 | headers: { 70 | control: 'object', 71 | }, 72 | fixed: 'boolean', 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /components/src/stories/Dialog.stories.js: -------------------------------------------------------------------------------- 1 | import Dialog from '~widgets/dialog/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-dialog', Dialog); 5 | 6 | export default { 7 | title: 'Components/Dialog', 8 | component: Dialog, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | }; 13 | 14 | export const Component = { 15 | render: (args) => ({ 16 | setup() { 17 | return { args }; 18 | }, 19 | template: ` 20 | 21 |
22 | {{ args.content }} 23 |
24 |
`, 25 | }), 26 | 27 | args: { 28 | value: true, 29 | title: 'Dialog Title', 30 | actions: ['cancel', 'submit'], 31 | height: '500px', 32 | width: '800px', 33 | isValid: true, 34 | submitLabel: 'Submit', 35 | content: 'This is the dialog content :-)', 36 | }, 37 | decorators: [() => ({ template: '
' })], 38 | }; 39 | -------------------------------------------------------------------------------- /components/src/stories/Icon.stories.js: -------------------------------------------------------------------------------- 1 | import Icon from '~widgets/icon/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | import * as iconsAnimated from '@cloudblueconnect/material-svg/animated'; 5 | import * as iconsBaseline from '@cloudblueconnect/material-svg/baseline'; 6 | 7 | registerWidget('ui-icon', Icon); 8 | 9 | export default { 10 | title: 'Components/Icon', 11 | component: Icon, 12 | parameters: { 13 | layout: 'centered', 14 | }, 15 | argTypes: { 16 | iconName: { 17 | options: Object.keys(iconsBaseline), 18 | control: { 19 | type: 'select', 20 | }, 21 | }, 22 | }, 23 | }; 24 | 25 | export const Component = { 26 | render: (args) => ({ 27 | setup() { 28 | return { args }; 29 | }, 30 | template: '', 31 | }), 32 | args: { 33 | iconName: 'googleSnowboardingBaseline', 34 | size: '32', 35 | color: '#757575', 36 | }, 37 | }; 38 | 39 | export const Animated = { 40 | name: 'All animated', 41 | render: () => ({ 42 | setup() { 43 | return {}; 44 | }, 45 | template: 46 | '
' + 47 | Object.keys(iconsAnimated) 48 | .map( 49 | (icon) => 50 | `
${icon}
`, 51 | ) 52 | .join('') + 53 | '
', 54 | }), 55 | args: {}, 56 | argTypes: { 57 | iconName: { control: false }, 58 | size: { control: false }, 59 | color: { control: false }, 60 | }, 61 | }; 62 | 63 | export const Baseline = { 64 | name: 'All baseline', 65 | render: () => ({ 66 | setup() { 67 | return {}; 68 | }, 69 | template: 70 | '
' + 71 | Object.keys(iconsBaseline) 72 | .map( 73 | (icon) => 74 | `
${icon}
`, 75 | ) 76 | .join('') + 77 | '
', 78 | }), 79 | args: {}, 80 | argTypes: { 81 | iconName: { control: false }, 82 | size: { control: false }, 83 | color: { control: false }, 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /components/src/stories/Introduction.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Overview 6 | 7 | The CloudBlue Connect platform provides a comprehensive suite of JavaScript UI controls that enables developers to build custom user interfaces quickly and easily. With this library, users can create custom forms and user interfaces with a wide range of components such as buttons, checkboxes, dropdowns, and text fields. This library also features an intuitive API that allows developers to customize the behavior of their UI components to match their application's specific requirements. In addition, this library includes a comprehensive set of tools that help developers optimize performance and speed up development time. With the CloudBlue Connect platform, developers can create modern, powerful, and user-friendly UI controls that are sure to meet their application's needs. 8 | 9 | UI controls are a core part of any application's user interface, and the CloudBlue Connect platform provides a set of standards-compliant UI controls that help ensure proper user experience. This library adheres to industry standards such as HTML5, CSS3, and JavaScript, allowing developers to create consistent and intuitive user interfaces across all platforms. Furthermore, the library makes use of modern technologies, providing developers with a cutting-edge set of tools to create beautiful and responsive user interfaces. With the CloudBlue Connect platform, developers can create user interfaces that are both reliable and user friendly. 10 | 11 | # Questions? 12 | 13 | Reach out to the CloudBlue team on [Github](https://github.com/cloudblue/connect-ui-toolkit) 14 | -------------------------------------------------------------------------------- /components/src/stories/Menu.stories.js: -------------------------------------------------------------------------------- 1 | import Menu from '~widgets/menu/widget.vue'; 2 | import Button from '~widgets/button/widget.vue'; 3 | import registerWidget from '~core/registerWidget'; 4 | 5 | registerWidget('ui-menu', Menu); 6 | registerWidget('ui-button', Button); 7 | 8 | export const Component = { 9 | render: (args) => ({ 10 | setup() { 11 | return { args }; 12 | }, 13 | template: ` 14 | 15 | 22 |
23 |

Lorem ipsum dolor sit amet

24 |
25 |
26 | `, 27 | }), 28 | 29 | args: { 30 | align: 'left', 31 | closeOnClickInside: false, 32 | fullWidth: false, 33 | }, 34 | }; 35 | 36 | export default { 37 | title: 'Components/Menu', 38 | component: Menu, 39 | parameters: { 40 | layout: 'centered', 41 | }, 42 | 43 | argTypes: { 44 | closeOnClickInside: 'boolean', 45 | fullWidth: 'boolean', 46 | align: { 47 | options: ['right', 'left'], 48 | control: { 49 | type: 'select', 50 | }, 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /components/src/stories/Navigation.stories.js: -------------------------------------------------------------------------------- 1 | import cNav from '~widgets/navigation/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-nav', cNav); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: 12 | '
', 13 | methods: { 14 | setTab({ detail }) { 15 | this.args.currentTab = detail; 16 | }, 17 | goBack() { 18 | alert('The back button was clicked!'); 19 | }, 20 | }, 21 | }), 22 | 23 | args: { 24 | title: 'Title', 25 | assistiveTitle: 'Assistive title', 26 | currentTab: 'first', 27 | tabs: [ 28 | { value: 'first', label: 'First tab' }, 29 | { value: 'second', label: 'Second tab' }, 30 | ], 31 | showBackButton: false, 32 | }, 33 | }; 34 | 35 | export default { 36 | title: 'Components/Navigation', 37 | component: cNav, 38 | parameters: { 39 | layout: 'centered', 40 | }, 41 | argTypes: { 42 | title: 'text', 43 | assistiveTitle: 'text', 44 | tabs: { 45 | control: 'object', 46 | }, 47 | currentTab: { 48 | control: 'select', 49 | options: Component.args.tabs.map((v) => v.value), 50 | }, 51 | showBackButton: 'boolean', 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /components/src/stories/Radio.stories.js: -------------------------------------------------------------------------------- 1 | import Radio from '~widgets/radio/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-radio', Radio); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: ``, 12 | }), 13 | 14 | args: { 15 | label: 'Option', 16 | selectedValue: 'foo', 17 | radioValue: 'foo', 18 | }, 19 | }; 20 | 21 | export default { 22 | title: 'Components/Radio', 23 | component: Radio, 24 | parameters: { 25 | layout: 'centered', 26 | }, 27 | argTypes: { 28 | label: 'text', 29 | radioValue: 'text', 30 | selectedValue: 'text', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /components/src/stories/Select.stories.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | import Select from '~widgets/select/widget.vue'; 4 | import registerWidget from '~core/registerWidget'; 5 | 6 | registerWidget('ui-select', Select); 7 | 8 | export const Basic = { 9 | name: 'Basic options', 10 | render: (args) => ({ 11 | setup() { 12 | return { args }; 13 | }, 14 | template: '', 15 | }), 16 | 17 | args: { 18 | label: 'Label text', 19 | modelValue: '', 20 | hint: 'Some hint text', 21 | options: ['foo', 'bar', 'baz'], 22 | }, 23 | }; 24 | 25 | export const Object = { 26 | name: 'Array of objects in options', 27 | render: Basic.render, 28 | args: { 29 | ...Basic.args, 30 | propValue: 'id', 31 | propText: 'name', 32 | options: [ 33 | { id: 'OBJ-123', name: 'The first object' }, 34 | { id: 'OBJ-456', name: 'The second object' }, 35 | { id: 'OBJ-789', name: 'The third object' }, 36 | ], 37 | }, 38 | }; 39 | 40 | export const Validation = { 41 | name: 'Input validation', 42 | render: Basic.render, 43 | 44 | args: { 45 | ...Basic.args, 46 | label: 'Select input with validation', 47 | hint: 'Select the second option if you want the validation to be successful', 48 | propValue: 'id', 49 | propText: 'name', 50 | required: true, 51 | options: [ 52 | { id: 'OBJ-123', name: 'The first object' }, 53 | { id: 'OBJ-456', name: 'The second object' }, 54 | { id: 'OBJ-789', name: 'The third object' }, 55 | ], 56 | rules: [(value) => value === 'OBJ-456' || 'You picked the wrong option :( '], 57 | }, 58 | }; 59 | 60 | export const Events = { 61 | name: 'Using v-model', 62 | render: (args) => ({ 63 | setup() { 64 | const selectedItem = ref(''); 65 | const setSelectedItem = (event) => { 66 | selectedItem.value = event.detail[0]; 67 | }; 68 | 69 | return { args, selectedItem, setSelectedItem }; 70 | }, 71 | template: ` 72 |
73 |

The current selected value is: {{ selectedItem }}

74 | 80 |
81 | `, 82 | }), 83 | args: Basic.args, 84 | }; 85 | 86 | export const Slots = { 87 | name: 'Custom element render', 88 | render: (args) => ({ 89 | setup() { 90 | const selectedItem = ref(''); 91 | const setSelectedItem = (event) => { 92 | selectedItem.value = event.detail[0]; 93 | }; 94 | 95 | return { args, selectedItem, setSelectedItem }; 96 | }, 97 | template: ` 98 |
99 | 105 | 106 | 107 | 108 | 109 | 110 |
111 | `, 112 | }), 113 | args: { 114 | ...Basic.args, 115 | label: 'This implementation uses the "selected" slot and the "optionTextFn"', 116 | options: [ 117 | { id: 'OBJ-123', name: 'The first object' }, 118 | { id: 'OBJ-456', name: 'The second object' }, 119 | { id: 'OBJ-789', name: 'The third object' }, 120 | ], 121 | optionTextFn: (item) => `${item.name} (${item.id})`, 122 | }, 123 | }; 124 | 125 | export default { 126 | title: 'Components/Select', 127 | component: Select, 128 | parameters: { 129 | layout: 'centered', 130 | }, 131 | argTypes: { 132 | label: 'text', 133 | modelValue: 'text', 134 | hint: 'text', 135 | propValue: 'text', 136 | propText: 'text', 137 | required: 'boolean', 138 | options: { control: 'array' }, 139 | }, 140 | }; 141 | -------------------------------------------------------------------------------- /components/src/stories/Status.stories.js: -------------------------------------------------------------------------------- 1 | import Status from '~widgets/status/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | import * as icons from '@cloudblueconnect/material-svg'; 5 | 6 | registerWidget('ui-status', Status); 7 | 8 | export default { 9 | title: 'Components/Status', 10 | component: Status, 11 | parameters: { 12 | layout: 'centered', 13 | }, 14 | argTypes: { 15 | iconName: { 16 | options: Object.keys(icons), 17 | control: { 18 | type: 'select', 19 | }, 20 | }, 21 | }, 22 | }; 23 | 24 | export const Component = { 25 | render: (args) => ({ 26 | components: { Status }, 27 | setup() { 28 | return { args }; 29 | }, 30 | template: '', 31 | }), 32 | 33 | args: { 34 | text: 'Status text', 35 | iconName: 'googleUpdateBaseline', 36 | iconColor: 'green', 37 | iconSize: '20', 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /components/src/stories/Table.stories.js: -------------------------------------------------------------------------------- 1 | import Table from '~widgets/table/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-table', Table); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: ` 12 | 13 | John 14 | Doe 15 | 57 16 | 17 | 18 | Mary 19 | Stephen 20 | 26 21 | 22 | `, 23 | }), 24 | 25 | args: { 26 | headers: [ 27 | { 28 | name: 'name', 29 | width: '80px', 30 | text: 'Name', 31 | }, 32 | { 33 | name: 'lastname', 34 | width: '80px', 35 | text: 'LastName', 36 | }, 37 | { 38 | name: 'age', 39 | width: '40px', 40 | text: 'Age', 41 | }, 42 | ], 43 | }, 44 | }; 45 | 46 | export default { 47 | title: 'Components/Table', 48 | component: Table, 49 | parameters: { 50 | layout: 'centered', 51 | }, 52 | argTypes: { 53 | headers: { 54 | control: 'object', 55 | }, 56 | fixed: 'boolean', 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /components/src/stories/Tabs.stories.js: -------------------------------------------------------------------------------- 1 | import cTabs from '~widgets/tabs/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-tabs', cTabs); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: 12 | '
Content for First tab
Content for Second tab
', 13 | methods: { 14 | setTab({ detail }) { 15 | this.args.currentTab = detail; 16 | }, 17 | }, 18 | }), 19 | 20 | args: { 21 | currentTab: 'first', 22 | tabs: [ 23 | { value: 'first', label: 'First tab' }, 24 | { value: 'second', label: 'Second tab' }, 25 | ], 26 | clean: false, 27 | }, 28 | }; 29 | 30 | export default { 31 | title: 'Components/Tabs', 32 | component: cTabs, 33 | parameters: { 34 | layout: 'centered', 35 | }, 36 | argTypes: { 37 | tabs: { 38 | control: 'object', 39 | }, 40 | currentTab: { 41 | control: 'select', 42 | options: Component.args.tabs.map((v) => v.value), 43 | }, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /components/src/stories/TextField.stories.js: -------------------------------------------------------------------------------- 1 | import isEmail from 'validator/es/lib/isEmail'; 2 | 3 | import cTextField from '~widgets/textfield/widget.vue'; 4 | import registerWidget from '~core/registerWidget'; 5 | 6 | registerWidget('ui-textfield', cTextField); 7 | 8 | export const Basic = { 9 | name: 'Basic options', 10 | render: (args) => ({ 11 | setup() { 12 | return { args }; 13 | }, 14 | template: '', 15 | }), 16 | 17 | args: { 18 | label: 'Simple textfield', 19 | hint: 'This is a hint for the text field input', 20 | value: '', 21 | placeholder: 'Placeholder text', 22 | suffix: '', 23 | }, 24 | }; 25 | 26 | export const Validation = { 27 | name: 'Input validation', 28 | render: Basic.render, 29 | 30 | args: { 31 | label: 'Text field with validation', 32 | hint: 'This is a text field with validation. The value should be an email', 33 | value: '', 34 | placeholder: 'john.doe@example.com', 35 | required: true, 36 | rules: [ 37 | (value) => !!value || 'This field is required', 38 | (value) => isEmail(value) || 'The value is not a valid email address', 39 | ], 40 | }, 41 | }; 42 | 43 | export default { 44 | title: 'Components/TextField', 45 | component: cTextField, 46 | parameters: { 47 | layout: 'centered', 48 | }, 49 | argTypes: { 50 | label: 'text', 51 | value: 'text', 52 | placeholder: 'text', 53 | suffix: 'text', 54 | required: 'boolean', 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /components/src/stories/Textarea.stories.js: -------------------------------------------------------------------------------- 1 | import Textarea from '~widgets/textarea/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-textarea', Textarea); 5 | 6 | export const Basic = { 7 | name: 'Basic options', 8 | render: (args) => ({ 9 | setup() { 10 | return { args }; 11 | }, 12 | template: '', 13 | }), 14 | 15 | args: { 16 | label: 'Simple textarea', 17 | hint: 'This is a hint for the text area input', 18 | value: '', 19 | placeholder: 'Placeholder text', 20 | }, 21 | }; 22 | 23 | export const Validation = { 24 | name: 'Input validation', 25 | render: Basic.render, 26 | 27 | args: { 28 | label: 'Text area with validation', 29 | hint: 'This is a text area with validation. The text should be < 30 characters.', 30 | value: '', 31 | placeholder: 'Lorem ipsum dolor sit amet', 32 | required: true, 33 | rules: [ 34 | (value) => !!value || 'This field is required', 35 | (value) => value.length < 30 || 'The value must be less than 30 characters', 36 | ], 37 | }, 38 | }; 39 | 40 | export default { 41 | title: 'Components/Textarea', 42 | component: Textarea, 43 | parameters: { 44 | layout: 'centered', 45 | }, 46 | argTypes: { 47 | value: 'text', 48 | readonly: 'boolean', 49 | hint: 'text', 50 | placeholder: 'text', 51 | required: 'boolean', 52 | autoGrow: 'boolean', 53 | noBorder: 'boolean', 54 | monospace: 'boolean', 55 | rows: 'number', 56 | label: 'string', 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /components/src/stories/View.stories.js: -------------------------------------------------------------------------------- 1 | import cView from '~widgets/view/widget.vue'; 2 | import registerWidget from '~core/registerWidget'; 3 | 4 | registerWidget('ui-view', cView); 5 | 6 | export const Component = { 7 | render: (args) => ({ 8 | setup() { 9 | return { args }; 10 | }, 11 | template: ` 12 | 13 |
Loader slot: Loading...
14 |
15 |

First tab content

16 |

Second tab content

17 |
18 | `, 19 | methods: { 20 | goBack() { 21 | alert('The back button was clicked!'); 22 | }, 23 | }, 24 | }), 25 | 26 | args: { 27 | loading: false, 28 | noPadded: false, 29 | title: 'Title', 30 | assistiveTitle: 'Assistive title', 31 | currentTab: '', 32 | tabs: [ 33 | { value: 'first', label: 'First tab' }, 34 | { value: 'second', label: 'Second tab' }, 35 | ], 36 | showBackButton: false, 37 | }, 38 | }; 39 | 40 | export default { 41 | title: 'Components/View', 42 | component: cView, 43 | parameters: { 44 | layout: 'centered', 45 | }, 46 | argTypes: { 47 | loading: 'boolean', 48 | noPadded: 'boolean', 49 | title: 'text', 50 | assistiveTitle: 'text', 51 | tabs: { 52 | control: 'object', 53 | }, 54 | currentTab: 'text', 55 | showBackButton: 'boolean', 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /components/src/widgets/alert/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | 3 | import Alert from './widget.vue'; 4 | 5 | describe('Alert component', () => { 6 | let wrapper; 7 | 8 | beforeEach(() => { 9 | wrapper = shallowMount(Alert, { 10 | props: { 11 | modelValue: true, 12 | message: 'this is a message', 13 | type: 'success', 14 | }, 15 | global: { 16 | renderStubDefaultSlot: true, 17 | }, 18 | }); 19 | }); 20 | 21 | describe('render', () => { 22 | test('renders the base component', () => { 23 | expect(wrapper.get('.alert-holder').attributes()).toEqual( 24 | expect.objectContaining({ 25 | class: 'alert-holder alert_success', 26 | modelvalue: 'true', 27 | }), 28 | ); 29 | 30 | const text = wrapper.find('.alert__text'); 31 | 32 | expect(text.text()).toEqual('this is a message'); 33 | 34 | const icon = wrapper.find('ui-icon'); 35 | 36 | expect(icon.exists()).toEqual(true); 37 | expect(icon.attributes()).toEqual( 38 | expect.objectContaining({ 39 | iconname: 'googleInfoBaseline', 40 | color: '#0bb071', 41 | size: '24', 42 | }), 43 | ); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /components/src/widgets/alert/widget.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 47 | 48 | 98 | -------------------------------------------------------------------------------- /components/src/widgets/autocomplete/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Autocomplete from './widget.vue'; 3 | 4 | describe('Autocomplete', () => { 5 | let wrapper; 6 | 7 | const options = [ 8 | { id: '1', name: 'Andorra' }, 9 | { id: '2', name: 'Poland' }, 10 | { id: '3', name: 'Spain' }, 11 | ]; 12 | 13 | beforeEach(() => { 14 | wrapper = mount(Autocomplete, { 15 | props: { 16 | label: 'Countries', 17 | options, 18 | propText: 'name', 19 | propValue: 'id', 20 | required: true, 21 | hint: 'Select a country', 22 | }, 23 | }); 24 | }); 25 | 26 | describe('render', () => { 27 | it('renders the base component', () => { 28 | expect(wrapper.exists()).toBe(true); 29 | expect(wrapper.find('.autocomplete').exists()).toBe(true); 30 | expect(wrapper.find('ui-select').exists()).toBe(true); 31 | expect(wrapper.find('ui-textfield').exists()).toBe(true); 32 | }); 33 | 34 | it('filters options based on user input', async () => { 35 | const textField = wrapper.find('ui-textfield'); 36 | 37 | await textField.trigger('input', { detail: ['an'] }); 38 | 39 | const filteredOptions = wrapper.vm.filteredOptions; 40 | 41 | expect(filteredOptions.length).toBe(2); 42 | expect(filteredOptions).toEqual([ 43 | { id: '1', name: 'Andorra' }, 44 | { id: '2', name: 'Poland' }, 45 | ]); 46 | }); 47 | 48 | it('updates selected value correctly', async () => { 49 | const select = wrapper.find('ui-select'); 50 | 51 | await select.trigger('value-change', { detail: ['some option'] }); 52 | 53 | const selected = wrapper.vm.selected; 54 | 55 | expect(selected).toBe('some option'); 56 | }); 57 | 58 | it('clears the user input after selection', async () => { 59 | const select = wrapper.find('ui-select'); 60 | const textfield = wrapper.find('ui-textfield'); 61 | 62 | await textfield.trigger('input', { detail: ['spa'] }); 63 | 64 | let userInput = wrapper.vm.userInput; 65 | 66 | expect(userInput).toBe('spa'); 67 | 68 | await select.trigger('value-change', { detail: ['Spain'] }); 69 | 70 | userInput = wrapper.vm.userInput; 71 | 72 | expect(userInput).toBe(''); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /components/src/widgets/autocomplete/widget.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 113 | 114 | 138 | -------------------------------------------------------------------------------- /components/src/widgets/card/widget.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 42 | 43 | 93 | -------------------------------------------------------------------------------- /components/src/widgets/complexTable/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import ComplexTable from './widget.vue'; 3 | import { nextTick } from 'vue'; 4 | 5 | describe('ComplexTable widget', () => { 6 | let result; 7 | 8 | describe('computed', () => { 9 | describe('#totalPages', () => { 10 | it('returns the total amount of pages', () => { 11 | const wrapper = mount(ComplexTable, { 12 | propsData: { 13 | totalItems: 35, 14 | items: ['hh'], 15 | headers: ['jj'], 16 | }, 17 | }); 18 | 19 | result = wrapper.vm.totalPages; 20 | 21 | expect(result).toEqual(4); 22 | }); 23 | }); 24 | 25 | describe('#previousButtonDisabled', () => { 26 | it('returns true if the current page is 1', () => { 27 | const wrapper = mount(ComplexTable, { 28 | propsData: { 29 | totalItems: 35, 30 | items: ['hh'], 31 | headers: ['jj'], 32 | currentPage: 1, 33 | }, 34 | }); 35 | 36 | result = wrapper.vm.previousButtonDisabled; 37 | 38 | expect(result).toEqual(true); 39 | }); 40 | 41 | it('returns false if the current page is not 1', () => { 42 | const wrapper = mount(ComplexTable, { 43 | propsData: { 44 | totalItems: 35, 45 | items: ['hh'], 46 | headers: ['jj'], 47 | currentPage: 2, 48 | }, 49 | }); 50 | 51 | result = wrapper.vm.previousButtonDisabled; 52 | 53 | expect(result).toEqual(false); 54 | }); 55 | }); 56 | 57 | describe('#nextButtonDisabled', () => { 58 | it('returns true if the current page is the last one', () => { 59 | const wrapper = mount(ComplexTable, { 60 | propsData: { 61 | totalItems: 25, 62 | items: ['hh'], 63 | headers: ['jj'], 64 | currentPage: 3, 65 | }, 66 | }); 67 | 68 | result = wrapper.vm.nextButtonDisabled; 69 | 70 | expect(result).toEqual(true); 71 | }); 72 | 73 | it('returns false if the current page is not the last one', () => { 74 | const wrapper = mount(ComplexTable, { 75 | propsData: { 76 | totalItems: 35, 77 | items: ['hh'], 78 | headers: ['jj'], 79 | currentPage: 2, 80 | }, 81 | }); 82 | 83 | result = wrapper.vm.nextButtonDisabled; 84 | 85 | expect(result).toEqual(false); 86 | }); 87 | }); 88 | 89 | describe('#filterableHeaders', () => { 90 | it('returns the subarray of filterable headers', () => { 91 | const wrapper = mount(ComplexTable, { 92 | propsData: { 93 | totalItems: 35, 94 | items: ['hh'], 95 | headers: [ 96 | { value: 'name', text: 'Name', filterable: true }, 97 | { value: 'lastName', text: 'Lastname', filterable: true }, 98 | { value: 'age', text: 'Age' }, 99 | ], 100 | }, 101 | }); 102 | 103 | result = wrapper.vm.filterableHeaders; 104 | 105 | expect(result).toEqual([ 106 | { filterable: true, text: 'Name', value: 'name' }, 107 | { filterable: true, text: 'Lastname', value: 'lastName' }, 108 | ]); 109 | }); 110 | }); 111 | 112 | describe('#cleanFiltersApplied', () => { 113 | it('returns a list of filters applied with value', () => { 114 | const wrapper = mount(ComplexTable, { 115 | propsData: { 116 | totalItems: 35, 117 | items: ['hh'], 118 | headers: [ 119 | { value: 'name', text: 'Name', filterable: true }, 120 | { value: 'lastName', text: 'Lastname', filterable: true }, 121 | { value: 'age', text: 'Age' }, 122 | ], 123 | }, 124 | }); 125 | 126 | wrapper.get('ui-button').trigger('click'); 127 | const filterableItems = wrapper.findAll('.filter-item ui-textfield'); 128 | filterableItems[0].trigger('input', { detail: ['my name'] }); 129 | 130 | result = wrapper.vm.cleanFiltersApplied; 131 | 132 | expect(result).toEqual({ name: 'my name' }); 133 | }); 134 | }); 135 | }); 136 | 137 | describe('methods', () => { 138 | describe('#previousClicked', () => { 139 | it('emits previousClicked event', async () => { 140 | const wrapper = mount(ComplexTable, { 141 | propsData: { 142 | totalItems: 35, 143 | items: [], 144 | headers: ['jj'], 145 | currentPage: 2, 146 | }, 147 | }); 148 | 149 | result = wrapper.vm.previousClicked(); 150 | 151 | expect(wrapper.emitted('previousClicked')).toBeTruthy(); 152 | }); 153 | }); 154 | 155 | describe('#nextClicked', () => { 156 | it('emits nextClicked event', async () => { 157 | const wrapper = mount(ComplexTable, { 158 | propsData: { 159 | totalItems: 35, 160 | items: [], 161 | headers: ['jj'], 162 | currentPage: 2, 163 | }, 164 | }); 165 | 166 | result = wrapper.vm.nextClicked(); 167 | 168 | expect(wrapper.emitted('nextClicked')).toBeTruthy(); 169 | }); 170 | }); 171 | 172 | describe('#applyFilters', () => { 173 | it('emits filtersApplied event', () => { 174 | const wrapper = mount(ComplexTable, { 175 | propsData: { 176 | totalItems: 35, 177 | items: ['hh'], 178 | headers: [ 179 | { value: 'name', text: 'Name', filterable: true }, 180 | { value: 'lastName', text: 'Lastname', filterable: true }, 181 | { value: 'age', text: 'Age' }, 182 | ], 183 | }, 184 | }); 185 | 186 | wrapper.get('ui-button').trigger('click'); 187 | const filterableItems = wrapper.findAll('.filter-item ui-textfield'); 188 | filterableItems[0].trigger('input', { detail: ['my name'] }); 189 | 190 | result = wrapper.vm.applyFilters(); 191 | 192 | expect(wrapper.emitted('filtersApplied')).toEqual([ 193 | [ 194 | { 195 | name: 'my name', 196 | }, 197 | ], 198 | ]); 199 | }); 200 | }); 201 | 202 | describe('#prepareItems', () => { 203 | it('returns the first 10 items', () => { 204 | const itemsList = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']; 205 | const wrapper = mount(ComplexTable, { 206 | propsData: { 207 | totalItems: 35, 208 | items: itemsList, 209 | headers: ['jj'], 210 | currentPage: 2, 211 | }, 212 | }); 213 | 214 | result = wrapper.vm.prepareItems(itemsList); 215 | 216 | expect(result.length).toEqual(10); 217 | }); 218 | }); 219 | }); 220 | 221 | describe('watch', () => { 222 | describe('props.items', () => { 223 | it('trigger the itemsLoaded event', async () => { 224 | const wrapper = mount(ComplexTable, { 225 | propsData: { 226 | totalItems: 35, 227 | items: [], 228 | headers: ['jj'], 229 | }, 230 | }); 231 | 232 | wrapper.setProps({ 233 | items: ['other'], 234 | }); 235 | 236 | await nextTick(); 237 | 238 | expect(wrapper.emitted().itemsLoaded).toBeTruthy(); 239 | }); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /components/src/widgets/complexTable/widget.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 137 | 138 | 185 | -------------------------------------------------------------------------------- /components/src/widgets/dialog/widget.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 150 | 151 | 252 | -------------------------------------------------------------------------------- /components/src/widgets/icon/widget.spec.js: -------------------------------------------------------------------------------- 1 | import Icon from './widget.vue'; 2 | import { googleSnowboardingBaseline } from '@cloudblueconnect/material-svg'; 3 | 4 | describe('Icon', () => { 5 | let result; 6 | 7 | describe('computed', () => { 8 | describe('#icon', () => { 9 | it('should return icon based on iconName', () => { 10 | const component = Icon.setup( 11 | { iconName: 'googleSnowboardingBaseline' }, 12 | { expose: () => 'mock reqired for composition api' }, 13 | ); 14 | result = component.icon.value; 15 | 16 | expect(result).toEqual(googleSnowboardingBaseline); 17 | }); 18 | }); 19 | 20 | describe('#styles', () => { 21 | it('should return styles based on props', () => { 22 | const component = Icon.setup( 23 | { 24 | color: 'blue', 25 | size: '12', 26 | }, 27 | { expose: () => 'mock reqired for composition api' }, 28 | ); 29 | result = component.styles.value; 30 | 31 | expect(result).toEqual({ 32 | color: 'blue', 33 | height: '12px', 34 | width: '12px', 35 | }); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('methods', () => { 41 | describe('#addUnits', () => { 42 | it('should add "px" if size prop is passed as Number', () => { 43 | const component = Icon.setup( 44 | { size: '12' }, 45 | { expose: () => 'mock reqired for composition api' }, 46 | ); 47 | result = component.addUnits(12); 48 | 49 | expect(result).toEqual('12px'); 50 | }); 51 | 52 | it('should add px if size prop is passed as String, but without "px"', () => { 53 | const component = Icon.setup({ size: '12' }, { expose: (x) => x }); 54 | result = component.addUnits(12); 55 | 56 | expect(result).toEqual('12px'); 57 | }); 58 | 59 | it('shouldn\'t add px if size prop is passed as String with "px"', () => { 60 | const component = Icon.setup( 61 | { size: '12px' }, 62 | { expose: () => 'mock reqired for composition api' }, 63 | ); 64 | result = component.addUnits(12); 65 | 66 | expect(result).toEqual('12px'); 67 | }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /components/src/widgets/icon/widget.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 52 | 53 | 72 | -------------------------------------------------------------------------------- /components/src/widgets/menu/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Menu from './widget.vue'; 3 | 4 | describe('Menu component', () => { 5 | describe('methods', () => { 6 | describe('#toggle', () => { 7 | it('toggles menu to true when clicking', () => { 8 | const wrapper = mount(Menu); 9 | wrapper.vm.showMenu = false; 10 | wrapper.vm.toggle(wrapper.vm.showMenu); 11 | 12 | expect(wrapper.vm.showMenu).toBe(true); 13 | }); 14 | 15 | it('toggles menu back to false when clicking', () => { 16 | const wrapper = mount(Menu); 17 | wrapper.vm.showMenu = true; 18 | wrapper.vm.toggle(wrapper.vm.showMenu); 19 | 20 | expect(wrapper.vm.showMenu).toBe(false); 21 | }); 22 | 23 | it('does not toggle menu if disabled is true', async () => { 24 | const wrapper = mount(Menu); 25 | wrapper.vm.showMenu = true; 26 | await wrapper.setProps({ disabled: true }); 27 | wrapper.vm.toggle(wrapper.vm.showMenu); 28 | 29 | expect(wrapper.vm.showMenu).toBe(true); 30 | }); 31 | }); 32 | 33 | describe('#handleClickOutside', () => { 34 | it('closes menu when clicked outside menu bounds', async () => { 35 | const event = { composedPath: vi.fn().mockReturnValue(['slot', 'button']) }; 36 | const wrapper = mount(Menu); 37 | wrapper.vm.menu = 'div'; 38 | wrapper.vm.showMenu = true; 39 | await wrapper.vm.handleClickOutside(event); 40 | 41 | expect(wrapper.vm.showMenu).toBe(false); 42 | }); 43 | 44 | it('does not close menu when clicked inside menu bounds', async () => { 45 | const event = { composedPath: vi.fn().mockReturnValue(['slot', 'button']) }; 46 | const wrapper = mount(Menu); 47 | wrapper.vm.menu = 'button'; 48 | wrapper.vm.showMenu = true; 49 | await wrapper.vm.handleClickOutside(event); 50 | 51 | expect(wrapper.vm.showMenu).toBe(true); 52 | }); 53 | }); 54 | 55 | describe('#onClickInside', () => { 56 | it.each([ 57 | // expected, closeOnClickInside 58 | [false, true], 59 | [true, false], 60 | ])( 61 | 'should set showMenu.value=%s when closeOnClickInside=%s', 62 | (expected, closeOnClickInside) => { 63 | const wrapper = mount(Menu, { 64 | props: { 65 | closeOnClickInside, 66 | }, 67 | }); 68 | wrapper.vm.showMenu = true; 69 | wrapper.vm.onClickInside(); 70 | 71 | expect(wrapper.vm.showMenu).toEqual(expected); 72 | }, 73 | ); 74 | }); 75 | }); 76 | 77 | describe('onMounted', () => { 78 | it('adds up event listener on component mount', () => { 79 | const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); 80 | 81 | mount(Menu); 82 | 83 | expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); 84 | 85 | addEventListenerSpy.mockRestore(); 86 | }); 87 | }); 88 | 89 | describe('onUnmounted', () => { 90 | it('cleans up event listener on component unmount', async () => { 91 | const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); 92 | 93 | const wrapper = mount(Menu); 94 | await wrapper.unmount(); 95 | 96 | expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); 97 | 98 | removeEventListenerSpy.mockRestore(); 99 | }); 100 | }); 101 | 102 | describe('alignment class', () => { 103 | it('sets the "menu-content_align-right" class if align=right', async () => { 104 | const wrapper = mount(Menu, { 105 | props: { 106 | align: 'right', 107 | }, 108 | }); 109 | 110 | // Open menu 111 | await wrapper.find('.menu-trigger').trigger('click'); 112 | 113 | expect(wrapper.find('.menu-content_align-right').exists()).toEqual(true); 114 | }); 115 | 116 | it('sets the "menu-content_align-left" class if align=left', async () => { 117 | const wrapper = mount(Menu, { 118 | props: { 119 | align: 'left', 120 | }, 121 | }); 122 | 123 | // Open menu 124 | await wrapper.find('.menu-trigger').trigger('click'); 125 | 126 | expect(wrapper.find('.menu-content_align-left').exists()).toEqual(true); 127 | }); 128 | }); 129 | 130 | describe('fullWidth class', () => { 131 | it('adds the "menu-content_full-width" class if fullWidth is true', async () => { 132 | const wrapper = mount(Menu, { 133 | props: { 134 | fullWidth: true, 135 | }, 136 | }); 137 | 138 | // Open menu 139 | await wrapper.find('.menu-trigger').trigger('click'); 140 | 141 | expect(wrapper.find('.menu-content_full-width').exists()).toEqual(true); 142 | }); 143 | 144 | it('does not add the "menu-content_full-width" class fullWidth is false', async () => { 145 | const wrapper = mount(Menu, { 146 | props: { 147 | fullWidth: false, 148 | }, 149 | }); 150 | 151 | // Open menu 152 | await wrapper.find('.menu-trigger').trigger('click'); 153 | 154 | expect(wrapper.find('.menu-content_full-width').exists()).toEqual(false); 155 | }); 156 | }); 157 | 158 | describe('align prop validator', () => { 159 | it.each([ 160 | // expected, value 161 | [true, 'left'], 162 | [true, 'right'], 163 | [false, 'center'], 164 | [false, 'foo'], 165 | ])('returns %s if the prop value is %s', (expected, value) => { 166 | const result = Menu.props.align.validator(value); 167 | 168 | expect(result).toEqual(expected); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /components/src/widgets/menu/widget.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 92 | 93 | 116 | -------------------------------------------------------------------------------- /components/src/widgets/navigation/widget.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 93 | 94 | 199 | -------------------------------------------------------------------------------- /components/src/widgets/pad/widget.spec.js: -------------------------------------------------------------------------------- 1 | import Pad from './widget.vue'; 2 | 3 | describe('Pad widget', () => { 4 | let context; 5 | let result; 6 | 7 | describe('#data', () => { 8 | it('returns the initial data', () => { 9 | result = Pad.data(); 10 | 11 | expect(result).toEqual({ requested: null }); 12 | }); 13 | }); 14 | 15 | describe('computed', () => { 16 | describe('#opened', () => { 17 | it.each([ 18 | // expected, requested, pad, active 19 | [true, 'foo', 'foo', false], 20 | [true, 'foo', 'foo', true], 21 | [false, 'foo', 'bar', false], 22 | [false, 'foo', 'bar', true], 23 | [false, '', 'bar', false], 24 | [true, '', 'bar', true], 25 | ])('returns %s if requested=%s, pad=%s, active=%s', (expected, requested, pad, active) => { 26 | context = { requested, pad, active }; 27 | 28 | result = Pad.computed.opened(context); 29 | 30 | expect(result).toEqual(expected); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('lifecycle hooks', () => { 36 | describe('#created', () => { 37 | beforeEach(() => { 38 | context = { 39 | requested: null, 40 | $bus: { on: vi.fn() }, 41 | }; 42 | 43 | Pad.created.call(context); 44 | }); 45 | 46 | it('should call $bus.on', () => { 47 | expect(context.$bus.on).toHaveBeenCalledWith('click-tab', expect.any(Function)); 48 | }); 49 | 50 | it('should provide a callback, that will set requested to the value of the given tab', () => { 51 | const handler = context.$bus.on.mock.calls[0][1]; 52 | 53 | handler('foo'); 54 | 55 | expect(context.requested).toBe('foo'); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('watch', () => { 61 | describe('#opened', () => { 62 | beforeEach(() => { 63 | context = { 64 | $nextTick: vi.fn(), 65 | $injector: vi.fn(), 66 | }; 67 | }); 68 | 69 | it('should call $nextTick when value passed', async () => { 70 | await Pad.watch.opened.call(context, true); 71 | 72 | expect(context.$nextTick).toHaveBeenCalled(); 73 | }); 74 | 75 | it('should emit $injector "$size"', async () => { 76 | await Pad.watch.opened.call(context, true); 77 | 78 | expect(context.$injector).toHaveBeenCalledWith('$size'); 79 | }); 80 | 81 | it('should do nothing when value is falsy', async () => { 82 | await Pad.watch.opened.call(context, false); 83 | 84 | expect(context.$nextTick).not.toHaveBeenCalled(); 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /components/src/widgets/pad/widget.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 45 | 46 | 52 | -------------------------------------------------------------------------------- /components/src/widgets/radio/widget.spec.js: -------------------------------------------------------------------------------- 1 | import RadioInput from './widget.vue'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | 4 | describe('RadioInput component', () => { 5 | describe('render', () => { 6 | describe('when is checked', () => { 7 | test('renders the base component', () => { 8 | const wrapper = shallowMount(RadioInput, { 9 | props: { 10 | radioValue: 'foo', 11 | label: 'My radio input', 12 | selectedValue: 'foo', 13 | }, 14 | }); 15 | 16 | expect(wrapper.get('.radio-input ui-icon').attributes()).toEqual( 17 | expect.objectContaining({ 18 | iconname: 'googleRadioButtonCheckedBaseline', 19 | color: '#2C98F0', 20 | }), 21 | ); 22 | expect(wrapper.get('.radio-input__label').classes()).not.toContain( 23 | 'radio-input__label_empty', 24 | ); 25 | expect(wrapper.get('.radio-input__label-text').text()).toEqual('My radio input'); 26 | expect(wrapper.vm.isSelected).toEqual(true); 27 | expect(wrapper.vm.icon).toEqual('googleRadioButtonCheckedBaseline'); 28 | expect(wrapper.vm.iconColor).toEqual('#2C98F0'); 29 | }); 30 | }); 31 | 32 | describe('when is unchecked', () => { 33 | test('renders the base component', () => { 34 | const wrapper = shallowMount(RadioInput, { 35 | props: { 36 | radioValue: 'foo', 37 | label: 'My radio input', 38 | selectedValue: 'bar', 39 | }, 40 | }); 41 | 42 | expect(wrapper.get('.radio-input ui-icon').attributes()).toEqual( 43 | expect.objectContaining({ 44 | iconname: 'googleRadioButtonUncheckedBaseline', 45 | color: '', 46 | }), 47 | ); 48 | expect(wrapper.get('.radio-input__label').classes()).not.toContain( 49 | 'radio-input__label_empty', 50 | ); 51 | expect(wrapper.get('.radio-input__label-text').text()).toEqual('My radio input'); 52 | expect(wrapper.vm.isSelected).toEqual(false); 53 | expect(wrapper.vm.icon).toEqual('googleRadioButtonUncheckedBaseline'); 54 | expect(wrapper.vm.iconColor).toEqual(''); 55 | }); 56 | }); 57 | 58 | describe('when there is no label', () => { 59 | test('adds the "radio-input__label_empty" class to the label element if there is no label', () => { 60 | const wrapper = shallowMount(RadioInput, { 61 | props: { 62 | radioValue: 'foo', 63 | selectedValue: 'foo', 64 | label: '', 65 | }, 66 | }); 67 | 68 | expect(wrapper.get('.radio-input__label').classes()).toContain('radio-input__label_empty'); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('events', () => { 74 | describe('#select', () => { 75 | test('it triggers the selected event with the radio value', () => { 76 | const wrapper = shallowMount(RadioInput, { 77 | props: { 78 | radioValue: 'bar', 79 | selectedValue: 'foo', 80 | label: 'label', 81 | }, 82 | }); 83 | 84 | wrapper.vm.select(); 85 | 86 | expect(wrapper.emitted('selected')).toBeTruthy(); 87 | expect(wrapper.emitted()).toEqual({ selected: [['bar']] }); 88 | }); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /components/src/widgets/radio/widget.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 54 | 55 | 87 | -------------------------------------------------------------------------------- /components/src/widgets/select/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Select from './widget.vue'; 3 | import { nextTick } from 'vue'; 4 | 5 | describe('Select', () => { 6 | let wrapper; 7 | 8 | beforeEach(() => { 9 | wrapper = mount(Select, { 10 | props: { 11 | modelValue: '', 12 | options: ['foo', 'bar', 'baz'], 13 | label: 'My select', 14 | hint: 'Some random hint', 15 | }, 16 | }); 17 | }); 18 | 19 | describe('render', () => { 20 | it('renders the base component', () => { 21 | expect(wrapper.get('.select-input__label').text()).toEqual('My select (Optional)'); 22 | expect(wrapper.get('.select-input__hint').text()).toEqual('Some random hint'); 23 | expect(wrapper.get('.select-input__no-selection').text()).toEqual(''); 24 | }); 25 | 26 | it('renders a simple array of text elements', () => { 27 | const menuOptions = wrapper.findAll('.select-input__option'); 28 | 29 | expect(menuOptions.length).toEqual(3); 30 | expect(menuOptions[0].text()).toEqual('foo'); 31 | expect(menuOptions[1].text()).toEqual('bar'); 32 | expect(menuOptions[2].text()).toEqual('baz'); 33 | }); 34 | 35 | it('does not render the "(Optional)" text in the label if required is true', async () => { 36 | await wrapper.setProps({ 37 | required: true, 38 | }); 39 | 40 | expect(wrapper.get('.select-input__label').text()).toEqual('My select'); 41 | }); 42 | 43 | it('adds the disabled class if disabled is true', async () => { 44 | await wrapper.setProps({ 45 | disabled: true, 46 | }); 47 | 48 | expect(wrapper.get('.select-input__selected').classes()).toContain( 49 | 'select-input__selected_disabled', 50 | ); 51 | }); 52 | 53 | it('renders a complex array of objects', async () => { 54 | await wrapper.setProps({ 55 | options: [ 56 | { id: '123', external_id: 'ext-123', name: 'Foo' }, 57 | { id: '456', external_id: 'ext-456', name: 'Bar' }, 58 | { id: '789', external_id: 'ext-789', name: 'Baz' }, 59 | ], 60 | propValue: 'external_id', 61 | propText: 'name', 62 | }); 63 | 64 | const menuOptions = wrapper.findAll('.select-input__option'); 65 | 66 | expect(menuOptions.length).toEqual(3); 67 | expect(menuOptions[0].text()).toEqual('Foo'); 68 | expect(menuOptions[1].text()).toEqual('Bar'); 69 | expect(menuOptions[2].text()).toEqual('Baz'); 70 | }); 71 | 72 | it('can render option text based on the optionTextFn prop', async () => { 73 | await wrapper.setProps({ 74 | options: [ 75 | { id: '123', external_id: 'ext-123', name: 'Foo' }, 76 | { id: '456', external_id: 'ext-456', name: 'Bar' }, 77 | { id: '789', external_id: 'ext-789', name: 'Baz' }, 78 | ], 79 | optionTextFn: (option) => `${option.name} (${option.id})`, 80 | }); 81 | 82 | const menuOptions = wrapper.findAll('.select-input__option'); 83 | 84 | expect(menuOptions.length).toEqual(3); 85 | expect(menuOptions[0].text()).toEqual('Foo (123)'); 86 | expect(menuOptions[1].text()).toEqual('Bar (456)'); 87 | expect(menuOptions[2].text()).toEqual('Baz (789)'); 88 | }); 89 | }); 90 | 91 | describe('validation', () => { 92 | let rule1; 93 | let rule2; 94 | 95 | beforeEach(async () => { 96 | rule1 = vi.fn().mockReturnValue(true); 97 | rule2 = vi.fn().mockReturnValue('This field is invalid'); 98 | 99 | wrapper = mount(Select, { 100 | props: { 101 | modelValue: '', 102 | options: ['foo', 'bar', 'baz'], 103 | hint: 'Hint text', 104 | rules: [rule1, rule2], 105 | }, 106 | }); 107 | 108 | await wrapper.findAll('.select-input__option')[1].trigger('click'); 109 | }); 110 | 111 | it('validates the input value against the rules prop', () => { 112 | expect(rule1).toHaveBeenCalledWith('bar'); 113 | expect(rule2).toHaveBeenCalledWith('bar'); 114 | }); 115 | 116 | it('renders the error messages if validation fails', () => { 117 | expect(wrapper.get('.select-input__error-message').text()).toEqual('This field is invalid.'); 118 | }); 119 | 120 | it('does not render the hint if there is an error', () => { 121 | expect(wrapper.get('.select-input__hint').text()).not.toEqual('Hint text'); 122 | }); 123 | 124 | it('adds the "select-input_invalid" class to the element', () => { 125 | expect(wrapper.classes()).toContain('select-input_invalid'); 126 | }); 127 | }); 128 | 129 | describe('events', () => { 130 | describe('when an item is selected', () => { 131 | beforeEach(async () => { 132 | await wrapper.findAll('.select-input__option')[1].trigger('click'); 133 | }); 134 | 135 | it('renders the selected item', () => { 136 | expect(wrapper.get('.select-input__option_selected').text()).toEqual('bar'); 137 | expect(wrapper.get('.select-input__selected').text()).toEqual('bar'); 138 | }); 139 | 140 | it('emits the update:modelValue event with the selected value', () => { 141 | expect(wrapper.emitted('update:modelValue')[0]).toEqual(['bar']); 142 | }); 143 | 144 | it('emits the valueChange event with the selected value', () => { 145 | expect(wrapper.emitted('valueChange')[0]).toEqual(['bar']); 146 | }); 147 | }); 148 | 149 | describe('when the menu is opened', () => { 150 | it('adds the "select-input_focused" class', async () => { 151 | await wrapper.get('ui-menu').trigger('opened'); 152 | 153 | expect(wrapper.classes()).toContain('select-input_focused'); 154 | }); 155 | }); 156 | 157 | describe('when the menu is closed', () => { 158 | it('removes the "select-input_focused" class', async () => { 159 | // open the menu first 160 | await wrapper.get('ui-menu').trigger('opened'); 161 | 162 | await wrapper.get('ui-menu').trigger('closed'); 163 | 164 | expect(wrapper.classes()).not.toContain('select-input_focused'); 165 | }); 166 | }); 167 | }); 168 | 169 | describe('watch', () => { 170 | beforeEach(() => { 171 | wrapper = mount(Select, { 172 | props: { 173 | modelValue: '1', 174 | options: [ 175 | { id: '1', value: 'Option 1' }, 176 | { id: '2', value: 'Option 2' }, 177 | ], 178 | propValue: 'id', 179 | }, 180 | }); 181 | }); 182 | 183 | it('should update selectedOption when model changes', async () => { 184 | // Initial check 185 | expect(wrapper.vm.selectedOption).toEqual({ id: '1', value: 'Option 1' }); 186 | 187 | await wrapper.setProps({ modelValue: '2' }); 188 | await nextTick(); 189 | 190 | expect(wrapper.vm.selectedOption).toEqual({ id: '2', value: 'Option 2' }); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /components/src/widgets/select/widget.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 188 | 189 | 284 | -------------------------------------------------------------------------------- /components/src/widgets/status/widget.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 42 | 43 | 63 | -------------------------------------------------------------------------------- /components/src/widgets/tab/widget.spec.js: -------------------------------------------------------------------------------- 1 | import Tab from './widget.vue'; 2 | 3 | describe('Tab', () => { 4 | let context; 5 | let result; 6 | 7 | describe('#data', () => { 8 | it('returns the initial data', () => { 9 | result = Tab.data(); 10 | 11 | expect(result).toEqual({ requested: null }); 12 | }); 13 | }); 14 | 15 | describe('computed', () => { 16 | describe('#selected', () => { 17 | it.each([ 18 | // expected, requested, tab, active 19 | [true, 'foo', 'foo', false], 20 | [true, 'foo', 'foo', true], 21 | [false, 'foo', 'bar', false], 22 | [false, 'foo', 'bar', true], 23 | [false, '', 'bar', false], 24 | [true, '', 'bar', true], 25 | ])('returns %s if requested=%s, tab=%s, active=%s', (expected, requested, tab, active) => { 26 | context = { requested, tab, active }; 27 | 28 | result = Tab.computed.selected(context); 29 | 30 | expect(result).toEqual(expected); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('lifecycle hooks', () => { 36 | describe('#created', () => { 37 | beforeEach(() => { 38 | context = { 39 | active: false, 40 | requested: null, 41 | $bus: { on: vi.fn() }, 42 | open: vi.fn(), 43 | }; 44 | }); 45 | 46 | it('should call $bus.on', () => { 47 | Tab.created.call(context); 48 | 49 | expect(context.$bus.on).toHaveBeenCalledWith('click-tab', expect.any(Function)); 50 | }); 51 | 52 | it('should provide a callback, that will set requested to the value of the given tab', () => { 53 | Tab.created.call(context); 54 | const handler = context.$bus.on.mock.calls[0][1]; 55 | 56 | handler('foo'); 57 | 58 | expect(context.requested).toBe('foo'); 59 | }); 60 | 61 | it('should call open if active=true', () => { 62 | context.active = true; 63 | 64 | Tab.created.call(context); 65 | 66 | expect(context.open).toHaveBeenCalled(); 67 | }); 68 | 69 | it('should not call open if active=false', () => { 70 | context.active = false; 71 | 72 | Tab.created.call(context); 73 | 74 | expect(context.open).not.toHaveBeenCalled(); 75 | }); 76 | }); 77 | }); 78 | 79 | describe('methods', () => { 80 | describe('#open', () => { 81 | it('should call $bus.emit', () => { 82 | context = { 83 | tab: 'foo', 84 | $bus: { 85 | emit: vi.fn(), 86 | }, 87 | }; 88 | 89 | Tab.methods.open.call(context); 90 | 91 | expect(context.$bus.emit).toHaveBeenCalledWith('click-tab', 'foo'); 92 | }); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /components/src/widgets/tab/widget.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 43 | 44 | 83 | -------------------------------------------------------------------------------- /components/src/widgets/table/widget.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 | 37 | 101 | -------------------------------------------------------------------------------- /components/src/widgets/tabs/widget.spec.js: -------------------------------------------------------------------------------- 1 | import Tabs from './widget.vue'; 2 | 3 | const customEventConstructorSpy = vi.fn(); 4 | Object.defineProperty(global, 'CustomEvent', { 5 | writable: true, 6 | configurable: true, 7 | value: function (name, opts) { 8 | customEventConstructorSpy(name, opts); 9 | 10 | return { name }; 11 | }, 12 | }); 13 | 14 | describe('Tabs', () => { 15 | let context; 16 | let result; 17 | 18 | describe('methods', () => { 19 | describe('#open', () => { 20 | beforeEach(() => { 21 | context = { 22 | currentTab: 'foo', 23 | $el: { dispatchEvent: vi.fn() }, 24 | }; 25 | }); 26 | 27 | it('does nothing if tab.disabled = true', () => { 28 | Tabs.methods.open.call(context, { disabled: true, value: 'bar' }); 29 | 30 | expect(context.$el.dispatchEvent).not.toHaveBeenCalled(); 31 | }); 32 | 33 | it('does nothing if tab.value = currentTab', () => { 34 | Tabs.methods.open.call(context, { value: 'foo' }); 35 | 36 | expect(context.$el.dispatchEvent).not.toHaveBeenCalled(); 37 | }); 38 | 39 | it('creates a "click-tab" custom event with the correct properties', () => { 40 | Tabs.methods.open.call(context, { value: 'bar' }); 41 | 42 | expect(customEventConstructorSpy).toHaveBeenCalledWith('click-tab', { 43 | detail: 'bar', 44 | bubbles: true, 45 | composed: true, 46 | }); 47 | }); 48 | 49 | it('calls $el.dispatchEvent with the newly created event', () => { 50 | Tabs.methods.open.call(context, { value: 'bar' }); 51 | 52 | expect(context.$el.dispatchEvent).toHaveBeenCalledWith({ name: 'click-tab' }); 53 | }); 54 | }); 55 | 56 | describe('#linkClass', () => { 57 | beforeEach(() => { 58 | context = { currentTab: 'foo' }; 59 | }); 60 | 61 | it('returns the tab state classes if value != currentTab and disabled=true', () => { 62 | result = Tabs.methods.linkClass.call(context, { value: 'bar', disabled: true }); 63 | 64 | expect(result).toEqual({ 65 | tab_active: false, 66 | tab_disabled: true, 67 | }); 68 | }); 69 | 70 | it('returns the tab state classes if value == currentTab and disabled=false', () => { 71 | result = Tabs.methods.linkClass.call(context, { value: 'foo', disabled: false }); 72 | 73 | expect(result).toEqual({ 74 | tab_active: true, 75 | tab_disabled: false, 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('lifecycle hooks', () => { 82 | describe('#mounted', () => { 83 | beforeEach(() => { 84 | context = { 85 | currentTab: '', 86 | tabs: [{ value: 'foo' }, { value: 'bar' }], 87 | open: vi.fn(), 88 | }; 89 | }); 90 | 91 | it('does nothing if currentTab is truthy', () => { 92 | context.currentTab = 'bar'; 93 | 94 | Tabs.mounted.call(context); 95 | 96 | expect(context.open).not.toHaveBeenCalled(); 97 | }); 98 | 99 | it('does nothing if currentTab is falsy but there are no tabs', () => { 100 | context.tabs = []; 101 | 102 | Tabs.mounted.call(context); 103 | 104 | expect(context.open).not.toHaveBeenCalled(); 105 | }); 106 | 107 | it('calls open with the first tab otherwise', () => { 108 | Tabs.mounted.call(context); 109 | 110 | expect(context.open).toHaveBeenCalledWith({ value: 'foo' }); 111 | }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /components/src/widgets/tabs/widget.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 79 | 80 | 168 | -------------------------------------------------------------------------------- /components/src/widgets/textarea/widget.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | 3 | import TextArea from './widget.vue'; 4 | 5 | describe('TextArea component', () => { 6 | let wrapper; 7 | 8 | beforeEach(() => { 9 | wrapper = shallowMount(TextArea, { 10 | props: { 11 | modelValue: 'textarea text', 12 | noBorder: true, 13 | autoGrow: true, 14 | rows: 4, 15 | placeholder: 'placeholder', 16 | }, 17 | global: { 18 | renderStubDefaultSlot: true, 19 | }, 20 | }); 21 | }); 22 | 23 | describe('render', () => { 24 | test('renders the base component', () => { 25 | expect(wrapper.get('.textarea-field').attributes()).toEqual( 26 | expect.objectContaining({ 27 | class: 'textarea-field textarea-field_optional', 28 | }), 29 | ); 30 | 31 | expect(wrapper.get('textarea').attributes()).toEqual( 32 | expect.objectContaining({ 33 | class: 34 | 'textarea-field__input textarea-field__input_no-resize textarea-field__input_no-border', 35 | name: 'textarea', 36 | placeholder: 'placeholder', 37 | rows: '4', 38 | style: 'height: 96px;', 39 | }), 40 | ); 41 | }); 42 | }); 43 | 44 | describe('validation', () => { 45 | let rule1; 46 | let rule2; 47 | 48 | beforeEach(async () => { 49 | rule1 = vi.fn().mockReturnValue(true); 50 | rule2 = vi.fn().mockReturnValue('This field is invalid'); 51 | 52 | wrapper = shallowMount(TextArea, { 53 | props: { 54 | hint: 'Hint text', 55 | rules: [rule1, rule2], 56 | }, 57 | }); 58 | 59 | await wrapper.get('.textarea-field__input').setValue('foo'); 60 | }); 61 | 62 | it('validates the input value against the rules prop', () => { 63 | expect(rule1).toHaveBeenCalledWith('foo'); 64 | expect(rule2).toHaveBeenCalledWith('foo'); 65 | }); 66 | 67 | it('renders the error messages if validation fails', () => { 68 | expect(wrapper.get('.textarea-field__error-message').text()).toEqual( 69 | 'This field is invalid.', 70 | ); 71 | }); 72 | 73 | it('adds the "textarea-field_invalid" class to the element', () => { 74 | expect(wrapper.classes()).toContain('textarea-field_invalid'); 75 | }); 76 | }); 77 | 78 | describe('#calculateInputHeight', () => { 79 | test('calculates and sets the right height when is autogrow', () => { 80 | wrapper.vm.calculateInputHeight(); 81 | 82 | expect(wrapper.get('textarea').wrapperElement.style.height).toEqual('96px'); 83 | }); 84 | }); 85 | 86 | describe('#onMounted', () => { 87 | test('calls calculateInputHeight and sets the right height', () => { 88 | expect(wrapper.get('textarea').wrapperElement.style.height).toEqual('96px'); 89 | }); 90 | }); 91 | 92 | describe('watch', () => { 93 | test('calls calculateInputHeight and sets the right height', () => { 94 | expect(wrapper.get('textarea').wrapperElement.style.height).toEqual('96px'); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /components/src/widgets/textarea/widget.vue: -------------------------------------------------------------------------------- 1 |