diff --git a/.changeset/strange-cooks-decide.md b/.changeset/strange-cooks-decide.md new file mode 100644 index 000000000..5651600be --- /dev/null +++ b/.changeset/strange-cooks-decide.md @@ -0,0 +1,5 @@ +--- +"@frontify/app-bridge-theme": major +--- + +feat: AppBridgeTheme v1 - first stable release diff --git a/.github/workflows/app-bridge-theme-continuous-integration.yml b/.github/workflows/app-bridge-theme-continuous-integration.yml new file mode 100644 index 000000000..1df4cc82c --- /dev/null +++ b/.github/workflows/app-bridge-theme-continuous-integration.yml @@ -0,0 +1,46 @@ +name: App Bridge Theme CI + +on: + pull_request: + paths: + - packages/app-bridge-theme/** + +# Ensures that only one workflow per branch will run at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + app-bridge-theme-ci: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout default branch + uses: actions/checkout@v4 + with: + # Disabling shallow clone is recommended for improving relevancy of reporting + fetch-depth: 0 + + - name: Use pnpm + uses: pnpm/action-setup@v3 + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install npm dependencies + run: pnpm i --frozen-lockfile + + - name: Typecheck code + run: pnpm --stream --filter {packages/app-bridge-theme} typecheck + + - name: Lint code + run: pnpm --stream --filter {packages/app-bridge-theme} lint + + - name: Test code + run: pnpm --stream --filter {packages/app-bridge-theme} test diff --git a/CODEOWNERS b/CODEOWNERS index a2d1bdc74..fca9d5d4f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -183,4 +183,8 @@ packages/guideline-blocks-settings @Frontify/content-and-blocks packages/sidebar-settings @Frontify/content-and-blocks @Frontify/themes-and-navigation +# App Bridge Theme + +packages/app-bridge-theme @Frontify/themes-and-navigation + CODEOWNERS @Frontify/architecture diff --git a/package.json b/package.json index f39167e5e..5c1e4c546 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,10 @@ "private": true, "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67", "scripts": { - "build": "pnpm build:app-bridge && pnpm build:sidebar-settings && pnpm build:guideline-blocks-settings && pnpm build:platform-app && pnpm build:cli && pnpm build:app-bridge-app", + "build": "pnpm build:app-bridge && pnpm build:app-bridge-theme && pnpm build:sidebar-settings && pnpm build:guideline-blocks-settings && pnpm build:platform-app && pnpm build:cli && pnpm build:app-bridge-app", "build:app-bridge": "pnpm --stream --filter {packages/app-bridge} build", "build:app-bridge-app": "pnpm --stream --filter {packages/app-bridge-app} build", + "build:app-bridge-theme": "pnpm --stream --filter {packages/app-bridge-theme} build", "build:sidebar-settings": "pnpm --stream --filter {packages/sidebar-settings} build", "build:guideline-blocks-settings": "pnpm --stream --filter {packages/guideline-blocks-settings} build", "build:platform-app": "pnpm --stream --filter {packages/platform-app} build", diff --git a/packages/app-bridge-theme/.browserslistrc b/packages/app-bridge-theme/.browserslistrc new file mode 100644 index 000000000..d388ec78b --- /dev/null +++ b/packages/app-bridge-theme/.browserslistrc @@ -0,0 +1,3 @@ +last 2 versions +> 1% +not dead diff --git a/packages/app-bridge-theme/.gitignore b/packages/app-bridge-theme/.gitignore new file mode 100644 index 000000000..fc016d6fa --- /dev/null +++ b/packages/app-bridge-theme/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.idea +coverage +.DS_Store +package.json.d.ts diff --git a/packages/app-bridge-theme/.prettierrc b/packages/app-bridge-theme/.prettierrc new file mode 100644 index 000000000..607e8b5e1 --- /dev/null +++ b/packages/app-bridge-theme/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 120, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/packages/app-bridge-theme/CHANGELOG.md b/packages/app-bridge-theme/CHANGELOG.md new file mode 100644 index 000000000..57f938f21 --- /dev/null +++ b/packages/app-bridge-theme/CHANGELOG.md @@ -0,0 +1,216 @@ +# @frontify/app-bridge-theme + +## 0.0.0-alpha.34 + +### Minor Changes + +- [#1355](https://github.com/Frontify/brand-sdk/pull/1355) [`1a52c54`](https://github.com/Frontify/brand-sdk/commit/1a52c5482a3dcc195ff78f0ad9cc0ff4385c9bf6) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): remove portalToken from context + +## 0.0.0-alpha.33 + +### Minor Changes + +- [#1351](https://github.com/Frontify/brand-sdk/pull/1351) [`e301854`](https://github.com/Frontify/brand-sdk/commit/e3018543cc0bcb80cee5c3baeac298cac13a1d12) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): cleaning context and deprecated command `NavigateToDocumentSection` + +## 0.0.0-alpha.32 + +### Patch Changes + +- [#1348](https://github.com/Frontify/brand-sdk/pull/1348) [`df0e30d`](https://github.com/Frontify/brand-sdk/commit/df0e30d668da43d8a23684b8963ad67c26f34b10) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): previousPage, nextPage, and lastModified added to DocumentPage + +## 0.0.0-alpha.31 + +### Minor Changes + +- [#1335](https://github.com/Frontify/brand-sdk/pull/1335) [`5c1fd8b`](https://github.com/Frontify/brand-sdk/commit/5c1fd8be89f333cda3a15c48a6083ee51be286bf) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): remove platform apps references + +## 0.0.0-alpha.30 + +### Minor Changes + +- [#1319](https://github.com/Frontify/brand-sdk/pull/1319) [`0da6bbb`](https://github.com/Frontify/brand-sdk/commit/0da6bbb058b8995cc578528098a0b95bebce6cd9) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): page scroll info in context and listener hook + +## 0.0.0-alpha.29 + +### Minor Changes + +- [#1315](https://github.com/Frontify/brand-sdk/pull/1315) [`ed08f3e`](https://github.com/Frontify/brand-sdk/commit/ed08f3ec44eaa6b9f384d27d160a9dae0da16f6a) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeTheme): scroll page to top command + +## 0.0.0-alpha.28 + +### Patch Changes + +- [#1284](https://github.com/Frontify/brand-sdk/pull/1284) [`8a81ba2`](https://github.com/Frontify/brand-sdk/commit/8a81ba2445f5d13b55a106d2c3845b6316e79c4f) Thanks [@ragi96](https://github.com/ragi96)! - chore: update deps + +## 0.0.0-alpha.27 + +### Patch Changes + +- [#1238](https://github.com/Frontify/brand-sdk/pull/1238) [`10712c0`](https://github.com/Frontify/brand-sdk/commit/10712c01e2b5b4979b8649525515e490b6c4125b) Thanks [@anxobotana](https://github.com/anxobotana)! - refactor: deprecating navigateToDocumentSection in favor of navigateToSectionHeading. + +## 0.0.0-alpha.26 + +### Patch Changes + +- [#1222](https://github.com/Frontify/brand-sdk/pull/1222) [`22226ab`](https://github.com/Frontify/brand-sdk/commit/22226ab1266c83a8206a36431a419ad215ecbcb6) Thanks [@mike85](https://github.com/mike85)! - feat: export platform apps dialog + +## 0.0.0-alpha.25 + +### Minor Changes + +- [#1214](https://github.com/Frontify/brand-sdk/pull/1214) [`242db22`](https://github.com/Frontify/brand-sdk/commit/242db22097bbb8f14187ef5af42d2cd6374a8357) Thanks [@ragi96](https://github.com/ragi96)! - feat: add command to open and close the platform apps dialog + +### Patch Changes + +- [#1218](https://github.com/Frontify/brand-sdk/pull/1218) [`e8f80db`](https://github.com/Frontify/brand-sdk/commit/e8f80db9ebff09c575acc932f22ce65886c4d7ee) Thanks [@ragi96](https://github.com/ragi96)! - feat: add `isPlatformAppsDialogOpen` to context + +## 0.0.0-alpha.24 + +### Patch Changes + +- [#1210](https://github.com/Frontify/brand-sdk/pull/1210) [`0324ffb`](https://github.com/Frontify/brand-sdk/commit/0324ffb2b108d33a83403cfd796c3445626f230a) Thanks [@Kenny806](https://github.com/Kenny806)! - feat: extend AppBridgeTheme Event types to include State + +## 0.0.0-alpha.23 + +### Patch Changes + +- [#1208](https://github.com/Frontify/brand-sdk/pull/1208) [`b8d3922`](https://github.com/Frontify/brand-sdk/commit/b8d3922f5e10445426e9c9930ab004e41d8c3fb7) Thanks [@Kenny806](https://github.com/Kenny806)! - feat: Introduced a State for AppBridgeTheme + +## 0.0.0-alpha.22 + +### Patch Changes + +- [#1203](https://github.com/Frontify/brand-sdk/pull/1203) [`265c4d4`](https://github.com/Frontify/brand-sdk/commit/265c4d48a76cf5bd6e257123f0d793bd930f91da) Thanks [@anxobotana](https://github.com/anxobotana)! - feat: add activeSectionHeadingId to context + +## 0.0.0-alpha.21 + +### Minor Changes + +- [#1182](https://github.com/Frontify/brand-sdk/pull/1182) [`d3f4ac4`](https://github.com/Frontify/brand-sdk/commit/d3f4ac4385770dcfe6672dfd2f256f39d1473563) Thanks [@Kenny806](https://github.com/Kenny806)! - extend DocumentPage interface with isPublished property + +## 0.0.0-alpha.20 + +### Patch Changes + +- [#1110](https://github.com/Frontify/brand-sdk/pull/1110) [`18560db`](https://github.com/Frontify/brand-sdk/commit/18560dbd1bbfa3a76fdda0db5ed520b670c41979) Thanks [@mike85](https://github.com/mike85)! - feat(AppBridgeTheme): add EventRegistry + +## 0.0.0-alpha.19 + +### Patch Changes + +- [#1092](https://github.com/Frontify/brand-sdk/pull/1092) [`024b908`](https://github.com/Frontify/brand-sdk/commit/024b9089f68482aa908f08936c6a0c33cdaafb6c) Thanks [@anxobotana](https://github.com/anxobotana)! - refactor(appBridgeThemes): remove utilities folder + +## 0.0.0-alpha.18 + +### Patch Changes + +- [#1095](https://github.com/Frontify/brand-sdk/pull/1095) [`a70a9fe`](https://github.com/Frontify/brand-sdk/commit/a70a9fe0932e1a40c5d4d85e4fdcb3f008947b74) Thanks [@mike85](https://github.com/mike85)! - refactor(AppBridgeTheme): remove EventRegistry + +## 0.0.0-alpha.17 + +### Patch Changes + +- [#1084](https://github.com/Frontify/brand-sdk/pull/1084) [`3335235`](https://github.com/Frontify/brand-sdk/commit/3335235dc5f107280cac5c57a5008c4c9e2949e1) Thanks [@anxobotana](https://github.com/anxobotana)! - refactor(themes): add color utilities + +## 0.0.0-alpha.16 + +### Patch Changes + +- [#1075](https://github.com/Frontify/brand-sdk/pull/1075) [`772ed45`](https://github.com/Frontify/brand-sdk/commit/772ed451a39041e290597230f99de0727cd78b67) Thanks [@oliverschwendener](https://github.com/oliverschwendener)! - fix: export useEnabledFeatures hook + +## 0.0.0-alpha.15 + +### Patch Changes + +- [#1073](https://github.com/Frontify/brand-sdk/pull/1073) [`85f33ad`](https://github.com/Frontify/brand-sdk/commit/85f33ad945e8b1b59a289b1eb884f343a952116b) Thanks [@oliverschwendener](https://github.com/oliverschwendener)! - feat: add useEnabledFeatures + +## 0.0.0-alpha.14 + +### Patch Changes + +- [#1066](https://github.com/Frontify/brand-sdk/pull/1066) [`efcf755`](https://github.com/Frontify/brand-sdk/commit/efcf755dd1b0b5c2937e989445458f0e4dd992a6) Thanks [@oliverschwendener](https://github.com/oliverschwendener)! - feat(AppBridgeTheme): added command to open/close AiBrandAssistant dialog + feat(AppBridgeTheme): added list of enabled features to context + +## 0.0.0-alpha.13 + +### Patch Changes + +- [#1051](https://github.com/Frontify/brand-sdk/pull/1051) [`16ec9d1`](https://github.com/Frontify/brand-sdk/commit/16ec9d1ae5ced64bde74b275004cb632cba462b9) Thanks [@mike85](https://github.com/mike85)! - feat(AppBridgeTheme): add context hooks + +## 0.0.0-alpha.12 + +### Patch Changes + +- [#1046](https://github.com/Frontify/brand-sdk/pull/1046) [`facc3e6`](https://github.com/Frontify/brand-sdk/commit/facc3e647ce74bec3a8683889ebe493b88038e46) Thanks [@mike85](https://github.com/mike85)! - fix(AppBridgeTheme): update imports + +## 0.0.0-alpha.11 + +### Patch Changes + +- [#1031](https://github.com/Frontify/brand-sdk/pull/1031) [`54fd9e7`](https://github.com/Frontify/brand-sdk/commit/54fd9e7a4d973d7b47e67d5e7cc1c1309255e0ea) Thanks [@mike85](https://github.com/mike85)! - chore(AppBridgeTheme): add vitest to app bridge theme + +- [#1032](https://github.com/Frontify/brand-sdk/pull/1032) [`19346b8`](https://github.com/Frontify/brand-sdk/commit/19346b8a27cc1e2efb6cd6c61884496ddd67a34c) Thanks [@mike85](https://github.com/mike85)! - feat(AppBridgeTheme): add use language hook + +- [#1034](https://github.com/Frontify/brand-sdk/pull/1034) [`562990d`](https://github.com/Frontify/brand-sdk/commit/562990de5485cebebcb840ddb5abf74d1abe4357) Thanks [@mike85](https://github.com/mike85)! - feat: add useIsEditing hook + +- [#1040](https://github.com/Frontify/brand-sdk/pull/1040) [`6bcd595`](https://github.com/Frontify/brand-sdk/commit/6bcd595dacebd55520f1cf87bc8fcbf849cc80d2) Thanks [@anxobotana](https://github.com/anxobotana)! - feat: (AppBridgeTheme) documentNavigation in context and command + +## 0.0.0-alpha.10 + +### Minor Changes + +- [#1015](https://github.com/Frontify/brand-sdk/pull/1015) [`77088a8`](https://github.com/Frontify/brand-sdk/commit/77088a85a7c181e5040d7a2738fb9fd7d95dfd1d) Thanks [@bojangles-m](https://github.com/bojangles-m)! - feat(GuidelineSearchResult): added additional prop to `GuidelineSearchResult` + +## 0.0.0-alpha.9 + +### Patch Changes + +- [#983](https://github.com/Frontify/brand-sdk/pull/983) [`57b2f90`](https://github.com/Frontify/brand-sdk/commit/57b2f90c8042e05774e57c1065fc86242f468f48) Thanks [@anxobotana](https://github.com/anxobotana)! - fix: AppBridgeTheme command typing + +## 0.0.0-alpha.8 + +### Patch Changes + +- [#981](https://github.com/Frontify/brand-sdk/pull/981) [`02cd695`](https://github.com/Frontify/brand-sdk/commit/02cd695c896847691eae43ed774637614ad3fd32) Thanks [@anxobotana](https://github.com/anxobotana)! - feat: AppBridgeTheme update context keys + +## 0.0.0-alpha.7 + +### Patch Changes + +- [#978](https://github.com/Frontify/brand-sdk/pull/978) [`5eb0306`](https://github.com/Frontify/brand-sdk/commit/5eb030695cc105a3ed514a5b38840b015a8d85ac) Thanks [@Kenny806](https://github.com/Kenny806)! - feat: add a navigate command + +## 0.0.0-alpha.6 + +### Patch Changes + +- [#974](https://github.com/Frontify/brand-sdk/pull/974) [`508f51e`](https://github.com/Frontify/brand-sdk/commit/508f51e1de4d091f8761f4b7897940574d819eee) Thanks [@anxobotana](https://github.com/anxobotana)! - feat: templateContext and NavigationItem types + +## 0.0.0-alpha.5 + +### Patch Changes + +- [#971](https://github.com/Frontify/brand-sdk/pull/971) [`08b3506`](https://github.com/Frontify/brand-sdk/commit/08b3506f1b8dba87fed1b6eb3f379bf619327d45) Thanks [@Kenny806](https://github.com/Kenny806)! - fix: exports + +## 0.0.0-alpha.4 + +### Patch Changes + +- [#967](https://github.com/Frontify/brand-sdk/pull/967) [`599df4f`](https://github.com/Frontify/brand-sdk/commit/599df4fd1db1ad43a8163538513c36a9ab3e938a) Thanks [@Kenny806](https://github.com/Kenny806)! - fix: extend AppBridgeThemeEvent to include assetsChosen + +## 0.0.0-alpha.3 + +### Patch Changes + +- [#965](https://github.com/Frontify/brand-sdk/pull/965) [`578e3e4`](https://github.com/Frontify/brand-sdk/commit/578e3e40025b1fbc88959181759257fe0e71d874) Thanks [@Kenny806](https://github.com/Kenny806)! - Added a SubscribeMap type + +## 0.0.0-alpha.2 + +### Patch Changes + +- [#963](https://github.com/Frontify/brand-sdk/pull/963) [`43472d5`](https://github.com/Frontify/brand-sdk/commit/43472d5f7ea4fd6bcdc44dc26103d1c3ce92cf4c) Thanks [@anxobotana](https://github.com/anxobotana)! - fix(AppBridgeThemes): fix build and codeowners + +## 0.0.0-alpha.1 + +### Patch Changes + +- [#960](https://github.com/Frontify/brand-sdk/pull/960) [`7a88fb5`](https://github.com/Frontify/brand-sdk/commit/7a88fb512a8209ab377ef12a70e2c8484d5b6799) Thanks [@anxobotana](https://github.com/anxobotana)! - feat(AppBridgeThemes): initial alpha release of AppBridgeThemes diff --git a/packages/app-bridge-theme/README.md b/packages/app-bridge-theme/README.md new file mode 100644 index 000000000..6ada156a8 --- /dev/null +++ b/packages/app-bridge-theme/README.md @@ -0,0 +1,2 @@ +# App Bridge Theme +Package to establish communication between Frontify and third party themes diff --git a/packages/app-bridge-theme/eslint.config.mjs b/packages/app-bridge-theme/eslint.config.mjs new file mode 100644 index 000000000..7fb53b4b5 --- /dev/null +++ b/packages/app-bridge-theme/eslint.config.mjs @@ -0,0 +1,55 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +// @ts-check + +// @ts-expect-error No types available +import frontifyConfig from '@frontify/eslint-config-react'; +// @ts-expect-error No types available +import noticePlugin from 'eslint-plugin-notice'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/', 'coverage/', 'node_modules/', '**/*.md/**.ts'], + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + frontifyConfig, + { + files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts', '**/*.cjs'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + notice: noticePlugin, + }, + rules: { + // Copyright header rules + 'notice/notice': [ + 'error', + { + template: '/* (c) Copyright Frontify Ltd., all rights reserved. */\n\n', + messages: { + whenFailedToMatch: 'No Frontify copyright header set.', + }, + }, + ], + 'no-prototype-builtins': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-misused-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/require-await': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/no-unsafe-enum-comparison': 'warn', + '@typescript-eslint/restrict-plus-operands': 'warn', + }, + }, +); diff --git a/packages/app-bridge-theme/package.json b/packages/app-bridge-theme/package.json new file mode 100644 index 000000000..08cffbe77 --- /dev/null +++ b/packages/app-bridge-theme/package.json @@ -0,0 +1,65 @@ +{ + "name": "@frontify/app-bridge-theme", + "type": "module", + "version": "0.0.0", + "description": "Package to establish communication between Frontify and themes", + "author": "Frontify Developers ", + "repository": { + "type": "git", + "url": "https://github.com/Frontify/brand-sdk", + "directory": "packages/app-bridge-theme" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "package.json.d.ts" + ], + "engines": { + "node": ">=16" + }, + "scripts": { + "build": "ts-json-as-const ./package.json && vite build", + "dev": "vite build --watch", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18" + }, + "dependencies": {}, + "devDependencies": { + "@frontify/eslint-config-react": "^1.0.4", + "@testing-library/react": "^16.3.0", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^1.4.0", + "eslint": "^9.34.0", + "eslint-plugin-notice": "^1.0.0", + "happy-dom": "^18.0.1", + "prettier": "^3.6.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ts-json-as-const": "^1.0.7", + "type-fest": "^4.41.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", + "vite": "^5.4.19", + "vite-plugin-dts": "^3.9.1", + "vitest": "^3.2.4" + } +} diff --git a/packages/app-bridge-theme/src/AppBridgeTheme.ts b/packages/app-bridge-theme/src/AppBridgeTheme.ts new file mode 100644 index 000000000..4c6880004 --- /dev/null +++ b/packages/app-bridge-theme/src/AppBridgeTheme.ts @@ -0,0 +1,42 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type CommandRegistry } from './registries'; +import { + type AppBridgeThemeEvent, + type Context, + type ContextReturn, + type DispatchHandlerParameter, + type EventCallbackParameter, + type EventNameParameter, + type EventUnsubscribeFunction, + type GuidelineSearchResult, + type State, + type StateReturn, +} from './types'; + +export interface AppBridgeTheme { + dispatch( + dispatchHandler: DispatchHandlerParameter, + ): Promise; + + context(): ContextReturn; + context(key: Key): ContextReturn; + context(key?: keyof Context | void): unknown; + + state(): StateReturn; + state(key: Key): StateReturn; + state(key?: keyof State | void): unknown; + + subscribe( + eventName: EventNameParameter, + callback: EventCallbackParameter, + ): EventUnsubscribeFunction; + + /** + * @deprecated + * Search in the current Guideline for a given query. + * @param query - The query to search for. + * @param order - The order in which the results should be returned. Defaults to 'relevance'. + */ + searchInGuideline(query: string, order?: 'relevance' | 'newest' | 'oldest'): Promise; +} diff --git a/packages/app-bridge-theme/src/global.d.ts b/packages/app-bridge-theme/src/global.d.ts new file mode 100644 index 000000000..fddff887f --- /dev/null +++ b/packages/app-bridge-theme/src/global.d.ts @@ -0,0 +1,3 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +type Nullable = T | null; diff --git a/packages/app-bridge-theme/src/index.ts b/packages/app-bridge-theme/src/index.ts new file mode 100644 index 000000000..4ad8c9eff --- /dev/null +++ b/packages/app-bridge-theme/src/index.ts @@ -0,0 +1,6 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export * from './AppBridgeTheme'; +export * from './react'; +export * from './registries'; +export * from './types'; diff --git a/packages/app-bridge-theme/src/react/index.ts b/packages/app-bridge-theme/src/react/index.ts new file mode 100644 index 000000000..8b915f0ae --- /dev/null +++ b/packages/app-bridge-theme/src/react/index.ts @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export * from './useDocumentNavigation'; +export * from './useIsEditing'; +export * from './useCurrentLanguage'; +export * from './useDefaultLanguage'; +export * from './useActiveSectionHeadingId'; +export * from './useLanguages'; +export * from './usePortalNavigation'; +export * from './useSettings'; +export * from './useTemplateContext'; +export * from './useEnabledFeatures'; +export * from './useScrollableAreaAttributes'; diff --git a/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.spec.ts b/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.spec.ts new file mode 100644 index 000000000..6681bb5e7 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.spec.ts @@ -0,0 +1,81 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useActiveSectionHeadingId } from './useActiveSectionHeadingId'; + +const UPDATED_HEADING_ID = 321; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(null), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useActiveSectionHeadingId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with ActiveSectionHeadingId', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useActiveSectionHeadingId(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('activeSectionHeadingId'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useActiveSectionHeadingId(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial ActiveSectionHeadingId', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useActiveSectionHeadingId(appBridgeTheme)); + + expect(result.current).toBeNull(); + }); + + it('should update the ActiveSectionHeadingId on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useActiveSectionHeadingId(appBridgeTheme)); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_HEADING_ID); + stubs.subscribeStub.mock.calls[0][0](UPDATED_HEADING_ID); + } + }); + + expect(result.current).toEqual(UPDATED_HEADING_ID); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useActiveSectionHeadingId(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.ts b/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.ts new file mode 100644 index 000000000..f3251283c --- /dev/null +++ b/packages/app-bridge-theme/src/react/useActiveSectionHeadingId.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; + +export const useActiveSectionHeadingId = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore( + appBridge.context('activeSectionHeadingId').subscribe, + appBridge.context('activeSectionHeadingId').get, + ); +}; diff --git a/packages/app-bridge-theme/src/react/useCurrentLanguage.spec.ts b/packages/app-bridge-theme/src/react/useCurrentLanguage.spec.ts new file mode 100644 index 000000000..5f9c16fea --- /dev/null +++ b/packages/app-bridge-theme/src/react/useCurrentLanguage.spec.ts @@ -0,0 +1,82 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useCurrentLanguage } from './useCurrentLanguage'; + +const INITIAL_LANGUAGE = 'de'; +const UPDATED_LANGUAGE = 'es'; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_LANGUAGE), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useCurrentLanguage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with currentLanguage', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useCurrentLanguage(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('currentLanguage'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useCurrentLanguage(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial currentLanguage', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useCurrentLanguage(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_LANGUAGE); + }); + + it('should update the currentLanguage on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useCurrentLanguage(appBridgeTheme)); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_LANGUAGE); + stubs.subscribeStub.mock.calls[0][0](UPDATED_LANGUAGE); + } + }); + + expect(result.current).toEqual(UPDATED_LANGUAGE); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useCurrentLanguage(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useCurrentLanguage.ts b/packages/app-bridge-theme/src/react/useCurrentLanguage.ts new file mode 100644 index 000000000..c54ee4346 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useCurrentLanguage.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; + +export const useCurrentLanguage = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore( + appBridge.context('currentLanguage').subscribe, + appBridge.context('currentLanguage').get, + ); +}; diff --git a/packages/app-bridge-theme/src/react/useDefaultLanguage.spec.ts b/packages/app-bridge-theme/src/react/useDefaultLanguage.spec.ts new file mode 100644 index 000000000..0f93a4fdd --- /dev/null +++ b/packages/app-bridge-theme/src/react/useDefaultLanguage.spec.ts @@ -0,0 +1,82 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useDefaultLanguage } from './useDefaultLanguage'; + +const INITIAL_LANGUAGE = 'de'; +const UPDATED_LANGUAGE = 'es'; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_LANGUAGE), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useDefaultLanguage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with defaultLanguage', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useDefaultLanguage(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('defaultLanguage'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useDefaultLanguage(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial defaultLanguage', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useDefaultLanguage(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_LANGUAGE); + }); + + it('should update the defaultLanguage on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useDefaultLanguage(appBridgeTheme)); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_LANGUAGE); + stubs.subscribeStub.mock.calls[0][0](UPDATED_LANGUAGE); + } + }); + + expect(result.current).toEqual(UPDATED_LANGUAGE); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useDefaultLanguage(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useDefaultLanguage.ts b/packages/app-bridge-theme/src/react/useDefaultLanguage.ts new file mode 100644 index 000000000..58c9d780c --- /dev/null +++ b/packages/app-bridge-theme/src/react/useDefaultLanguage.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; + +export const useDefaultLanguage = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore( + appBridge.context('defaultLanguage').subscribe, + appBridge.context('defaultLanguage').get, + ); +}; diff --git a/packages/app-bridge-theme/src/react/useDocumentNavigation.spec.ts b/packages/app-bridge-theme/src/react/useDocumentNavigation.spec.ts new file mode 100644 index 000000000..0035ed9f6 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useDocumentNavigation.spec.ts @@ -0,0 +1,145 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; +import { + type DocumentPageNavigationItem, + type DocumentChildNavigationItem, + type DocumentNavigationItem, +} from '../types'; + +import { useDocumentNavigation } from './useDocumentNavigation'; + +const DocumentPageDummy = (id: number) => + ({ + type: 'document', + + id: () => { + return id; + }, + slug: (language: string) => { + return `dummy-page-slug-${id}-${language}`; + }, + + title: (language: string) => { + return `Dummy Page title - ${id} - ${language}`; + }, + + url: (language: string) => { + return `page-${id}-${language}`; + }, + }) as unknown as DocumentPageNavigationItem; + +const DocumentDummy = (id: number, children: DocumentChildNavigationItem[]) => + ({ + type: 'document', + + id: () => { + return id; + }, + + children: () => { + return children; + }, + + slug: (language: string) => { + return `dummy-doc-slug-${id}-${language}`; + }, + + title: (language: string) => { + return `Dummy Doc title - ${id} - ${language}`; + }, + + url: (language: string) => { + return `https://blah/doc/${id}/${language}`; + }, + }) as unknown as DocumentNavigationItem; + +const DOCUMENT_ID = 120; +const DOCUMENT_ID_2 = 130; +const DOCUMENT_PAGE_ID = 121; +const DOCUMENT_PAGE_ID_2 = 131; + +const DOCUMENT_DUMMY = DocumentDummy(DOCUMENT_ID, [DocumentPageDummy(DOCUMENT_PAGE_ID)]); +const DOCUMENT_DUMMY_2 = DocumentDummy(DOCUMENT_ID_2, [DocumentPageDummy(DOCUMENT_PAGE_ID_2)]); +const DOCUMENT_NAVIGATION = { + [DOCUMENT_ID]: [DocumentPageDummy(DOCUMENT_PAGE_ID)], +}; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), + dispatch: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(DOCUMENT_NAVIGATION), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + dispatch: stubs.dispatch, + }) as unknown as AppBridgeTheme; + +describe('useDocumentNavigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with DocumentNavigation', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useDocumentNavigation(appBridgeTheme, DOCUMENT_DUMMY)); + + expect(stubs.contextStub).toHaveBeenCalled(); + expect(stubs.dispatch).toHaveBeenCalled(); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useDocumentNavigation(appBridgeTheme, DOCUMENT_DUMMY)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial DocumentNavigation state', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useDocumentNavigation(appBridgeTheme, DOCUMENT_DUMMY)); + + expect(result.current).toEqual(DOCUMENT_NAVIGATION[DOCUMENT_ID]); + }); + + it('should update the DocumentNavigation state on change', async () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useDocumentNavigation(appBridgeTheme, DOCUMENT_DUMMY)); + + expect(result.current).toEqual(DOCUMENT_NAVIGATION[DOCUMENT_ID]); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(DOCUMENT_DUMMY_2); + stubs.subscribeStub.mock.calls[0][0](DOCUMENT_DUMMY_2); + } + }); + + expect(stubs.dispatch).toHaveBeenCalled(); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useDocumentNavigation(appBridgeTheme, DOCUMENT_DUMMY)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useDocumentNavigation.ts b/packages/app-bridge-theme/src/react/useDocumentNavigation.ts new file mode 100644 index 000000000..85f27af3f --- /dev/null +++ b/packages/app-bridge-theme/src/react/useDocumentNavigation.ts @@ -0,0 +1,20 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useEffect, useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; +import { hydrateContextDocumentNavigation } from '../registries/commands/HydrateContextDocumentNavigation'; +import { type DocumentNavigationItem, type DocumentChildNavigationItem } from '../types/Guideline'; + +export const useDocumentNavigation = (appBridge: AppBridgeTheme, document: DocumentNavigationItem) => { + const documentNavigation: Record = useSyncExternalStore( + appBridge.context('documentNavigation').subscribe, + appBridge.context('documentNavigation').get, + ); + + useEffect(() => { + appBridge.dispatch(hydrateContextDocumentNavigation(document.id())); + }, [appBridge, document]); + + return documentNavigation ? (documentNavigation[document.id()] ?? []) : []; +}; diff --git a/packages/app-bridge-theme/src/react/useEnabledFeatures.spec.ts b/packages/app-bridge-theme/src/react/useEnabledFeatures.spec.ts new file mode 100644 index 000000000..0d3b96d03 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useEnabledFeatures.spec.ts @@ -0,0 +1,84 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useEnabledFeatures } from './useEnabledFeatures'; + +const INITIAL_ENABLED_FEATURES: string[] = []; +const UPDATED_ENABLED_FEATURES = ['feature1', 'feature2']; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_ENABLED_FEATURES), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useEnabledFeatures', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with enabledFeatures', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useEnabledFeatures(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('enabledFeatures'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useEnabledFeatures(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial enabled features', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useEnabledFeatures(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_ENABLED_FEATURES); + }); + + it('should update the enabled features on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useEnabledFeatures(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_ENABLED_FEATURES); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_ENABLED_FEATURES); + stubs.subscribeStub.mock.calls[0][0](UPDATED_ENABLED_FEATURES); + } + }); + + expect(result.current).toEqual(UPDATED_ENABLED_FEATURES); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useEnabledFeatures(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useEnabledFeatures.ts b/packages/app-bridge-theme/src/react/useEnabledFeatures.ts new file mode 100644 index 000000000..28372e980 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useEnabledFeatures.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +export const useEnabledFeatures = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore( + appBridge.context('enabledFeatures').subscribe, + appBridge.context('enabledFeatures').get, + ); +}; diff --git a/packages/app-bridge-theme/src/react/useIsEditing.spec.ts b/packages/app-bridge-theme/src/react/useIsEditing.spec.ts new file mode 100644 index 000000000..0ef5e3a5d --- /dev/null +++ b/packages/app-bridge-theme/src/react/useIsEditing.spec.ts @@ -0,0 +1,84 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useIsEditing } from './useIsEditing'; + +const INITIAL_EDITOR_STATE = false; +const UPDATED_EDITOR_STATE = true; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_EDITOR_STATE), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useIsEditing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with isEditing', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useIsEditing(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('isEditing'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useIsEditing(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial editor state', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useIsEditing(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_EDITOR_STATE); + }); + + it('should update the editor state on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useIsEditing(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_EDITOR_STATE); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_EDITOR_STATE); + stubs.subscribeStub.mock.calls[0][0](UPDATED_EDITOR_STATE); + } + }); + + expect(result.current).toEqual(UPDATED_EDITOR_STATE); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useIsEditing(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useIsEditing.ts b/packages/app-bridge-theme/src/react/useIsEditing.ts new file mode 100644 index 000000000..b401c7156 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useIsEditing.ts @@ -0,0 +1,9 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +export const useIsEditing = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore(appBridge.context('isEditing').subscribe, appBridge.context('isEditing').get); +}; diff --git a/packages/app-bridge-theme/src/react/useLanguages.spec.ts b/packages/app-bridge-theme/src/react/useLanguages.spec.ts new file mode 100644 index 000000000..f0e7c9789 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useLanguages.spec.ts @@ -0,0 +1,116 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useLanguages } from './useLanguages'; + +const INITIAL_LANGUAGES = [ + { + isoCode: 'en', + name: 'English', + isDefault: true, + isDraft: false, + }, + { + isoCode: 'de', + name: 'Deutsch', + isDefault: false, + isDraft: false, + }, +]; +const UPDATED_LANGUAGES = [ + { + isoCode: 'en', + name: 'English', + isDefault: false, + isDraft: false, + }, + { + isoCode: 'de', + name: 'Deutsch', + isDefault: true, + isDraft: false, + }, + { + isoCode: 'fr', + name: 'Français', + isDefault: false, + isDraft: true, + }, +]; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_LANGUAGES), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useLanguages', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with languages', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useLanguages(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('languages'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useLanguages(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial languages state', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useLanguages(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_LANGUAGES); + }); + + it('should update the languages state on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useLanguages(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_LANGUAGES); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_LANGUAGES); + stubs.subscribeStub.mock.calls[0][0](UPDATED_LANGUAGES); + } + }); + + expect(result.current).toEqual(UPDATED_LANGUAGES); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useLanguages(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useLanguages.ts b/packages/app-bridge-theme/src/react/useLanguages.ts new file mode 100644 index 000000000..7f2967648 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useLanguages.ts @@ -0,0 +1,9 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; + +export const useLanguages = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore(appBridge.context('languages').subscribe, appBridge.context('languages').get); +}; diff --git a/packages/app-bridge-theme/src/react/usePortalNavigation.spec.ts b/packages/app-bridge-theme/src/react/usePortalNavigation.spec.ts new file mode 100644 index 000000000..11097b527 --- /dev/null +++ b/packages/app-bridge-theme/src/react/usePortalNavigation.spec.ts @@ -0,0 +1,94 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; +import { type DocumentNavigationItem } from '../types'; + +import { usePortalNavigation } from './usePortalNavigation'; + +const DocumentDummy = (id: number) => + ({ + type: 'document', + + id: () => { + return id; + }, + }) as unknown as DocumentNavigationItem; + +const INITIAL_PORTAL_NAVIGATION = [DocumentDummy(532), DocumentDummy(682), DocumentDummy(746)]; +const UPDATED_PORTAL_NAVIGATION = [DocumentDummy(8425), DocumentDummy(9634)]; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_PORTAL_NAVIGATION), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('usePortalNavigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with portalNavigation', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => usePortalNavigation(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('portalNavigation'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => usePortalNavigation(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial portalNavigation state', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => usePortalNavigation(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_PORTAL_NAVIGATION); + }); + + it('should update the portalNavigation state on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => usePortalNavigation(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_PORTAL_NAVIGATION); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_PORTAL_NAVIGATION); + stubs.subscribeStub.mock.calls[0][0](UPDATED_PORTAL_NAVIGATION); + } + }); + + expect(result.current).toEqual(UPDATED_PORTAL_NAVIGATION); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => usePortalNavigation(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/usePortalNavigation.ts b/packages/app-bridge-theme/src/react/usePortalNavigation.ts new file mode 100644 index 000000000..2588c220a --- /dev/null +++ b/packages/app-bridge-theme/src/react/usePortalNavigation.ts @@ -0,0 +1,14 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +export const usePortalNavigation = (appBridge: AppBridgeTheme) => { + const portalNavigation = useSyncExternalStore( + appBridge.context('portalNavigation').subscribe, + appBridge.context('portalNavigation').get, + ); + + return portalNavigation ?? []; +}; diff --git a/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.spec.ts b/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.spec.ts new file mode 100644 index 000000000..ec45133df --- /dev/null +++ b/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.spec.ts @@ -0,0 +1,83 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useScrollableAreaAttributes } from './useScrollableAreaAttributes'; + +const UPDATED_SCROLL_POSITION = { + scrollTop: 200, +}; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(null), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('scrollableAreaAttributes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with useScrollableAreaAttributes', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useScrollableAreaAttributes(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('scrollableAreaAttributes'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useScrollableAreaAttributes(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial ScrollableAreaAttributes', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useScrollableAreaAttributes(appBridgeTheme)); + + expect(result.current).toBeNull(); + }); + + it('should update the scrollableAreaAttributes on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useScrollableAreaAttributes(appBridgeTheme)); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_SCROLL_POSITION); + stubs.subscribeStub.mock.calls[0][0](UPDATED_SCROLL_POSITION); + } + }); + + expect(result.current).toEqual(UPDATED_SCROLL_POSITION); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useScrollableAreaAttributes(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.ts b/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.ts new file mode 100644 index 000000000..4bea30a52 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useScrollableAreaAttributes.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme.ts'; + +export const useScrollableAreaAttributes = (appBridge: AppBridgeTheme) => { + return useSyncExternalStore( + appBridge.context('scrollableAreaAttributes').subscribe, + appBridge.context('scrollableAreaAttributes').get, + ); +}; diff --git a/packages/app-bridge-theme/src/react/useSettings.spec.ts b/packages/app-bridge-theme/src/react/useSettings.spec.ts new file mode 100644 index 000000000..6094f552c --- /dev/null +++ b/packages/app-bridge-theme/src/react/useSettings.spec.ts @@ -0,0 +1,84 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +import { useSettings } from './useSettings'; + +const INITIAL_SETTINGS = { templateSettings: { color: 'red' }, templateAssets: { logo: [] } }; +const UPDATED_SETTINGS = { templateSettings: { color: 'blue', isSticky: false }, templateAssets: {} }; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_SETTINGS), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with settings', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useSettings(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('settings'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useSettings(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial settings', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useSettings(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_SETTINGS); + }); + + it('should update the settings on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useSettings(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_SETTINGS); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_SETTINGS); + stubs.subscribeStub.mock.calls[0][0](UPDATED_SETTINGS); + } + }); + + expect(result.current).toEqual(UPDATED_SETTINGS); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useSettings(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useSettings.ts b/packages/app-bridge-theme/src/react/useSettings.ts new file mode 100644 index 000000000..6253ad274 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useSettings.ts @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; + +export const useSettings = >(appBridge: AppBridgeTheme) => { + const settings = useSyncExternalStore(appBridge.context('settings').subscribe, appBridge.context('settings').get); + + const templateSettings = settings.templateSettings as T; + + return { templateSettings, templateAssets: settings.templateAssets }; +}; diff --git a/packages/app-bridge-theme/src/react/useTemplateContext.spec.ts b/packages/app-bridge-theme/src/react/useTemplateContext.spec.ts new file mode 100644 index 000000000..165b54093 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useTemplateContext.spec.ts @@ -0,0 +1,107 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; +import { type CoverPage, type DocumentLibrary, type TemplateContext } from '../types'; + +import { useTemplateContext } from './useTemplateContext'; + +const CoverPageDummy = (id: number) => + ({ + type: 'cover', + + id: () => { + return id; + }, + }) as unknown as CoverPage; + +const DocumentLibraryDummy = (id: number) => + ({ + type: 'document-library', + + id: () => { + return id; + }, + }) as unknown as DocumentLibrary; + +const INITIAL_TEMPLATE: TemplateContext = { type: 'cover', coverPage: CoverPageDummy(56), templateId: 'default' }; +const UPDATED_TEMPLATE: TemplateContext = { + type: 'library', + document: DocumentLibraryDummy(78), + templateId: 'default', +}; + +const stubs = vi.hoisted(() => ({ + contextStub: vi.fn(), + contextGetStub: vi.fn(), + subscribeStub: vi.fn(), + unsubscribeObserverStub: vi.fn(), +})); + +const stubbedAppBridgeTheme = () => + ({ + context: stubs.contextStub.mockReturnValue({ + get: stubs.contextGetStub.mockReturnValue(INITIAL_TEMPLATE), + subscribe: stubs.subscribeStub.mockReturnValue(stubs.unsubscribeObserverStub), + }), + }) as unknown as AppBridgeTheme; + +describe('useTemplateContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the context with template', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useTemplateContext(appBridgeTheme)); + + expect(stubs.contextStub).toHaveBeenCalledWith('template'); + }); + + it('should subscribe to context updates', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + renderHook(() => useTemplateContext(appBridgeTheme)); + + expect(stubs.subscribeStub).toHaveBeenCalledOnce(); + }); + + it('should return the correct initial template', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useTemplateContext(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_TEMPLATE); + }); + + it('should update the template on change', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { result } = renderHook(() => useTemplateContext(appBridgeTheme)); + + expect(result.current).toEqual(INITIAL_TEMPLATE); + + act(() => { + if (vi.isMockFunction(stubs.subscribeStub)) { + stubs.contextGetStub.mockReturnValue(UPDATED_TEMPLATE); + stubs.subscribeStub.mock.calls[0][0](UPDATED_TEMPLATE); + } + }); + + expect(result.current).toEqual(UPDATED_TEMPLATE); + }); + + it('should unsubscribe on unmount', () => { + const appBridgeTheme = stubbedAppBridgeTheme(); + + const { unmount } = renderHook(() => useTemplateContext(appBridgeTheme)); + + unmount(); + + expect(stubs.unsubscribeObserverStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-bridge-theme/src/react/useTemplateContext.ts b/packages/app-bridge-theme/src/react/useTemplateContext.ts new file mode 100644 index 000000000..963735e49 --- /dev/null +++ b/packages/app-bridge-theme/src/react/useTemplateContext.ts @@ -0,0 +1,10 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useSyncExternalStore } from 'react'; + +import { type AppBridgeTheme } from '../AppBridgeTheme'; +import { type TemplateContext } from '../types'; + +export const useTemplateContext = (appBridge: AppBridgeTheme): TemplateContext | null => { + return useSyncExternalStore(appBridge.context('template').subscribe, appBridge.context('template').get); +}; diff --git a/packages/app-bridge-theme/src/registries/CommandRegistry.ts b/packages/app-bridge-theme/src/registries/CommandRegistry.ts new file mode 100644 index 000000000..5e34dd299 --- /dev/null +++ b/packages/app-bridge-theme/src/registries/CommandRegistry.ts @@ -0,0 +1,24 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type Simplify } from 'type-fest'; + +import { type ObjectNameValidator } from '../types'; + +export type CommandRegistry = CommandNameValidator<{ + openSearchDialog: void; + closeSearchDialog: void; + openAiBrandAssistantDialog: void; + closeAiBrandAssistantDialog: void; + navigate: string; + navigateToSectionHeading: number | string; + hydrateContextDocumentNavigation: number; + scrollPageToTop: void; +}>; + +type CommandNameValidator = Simplify< + ObjectNameValidator +>; + +type CommandNamePattern = { [commandName: `${CommandVerb}${string}`]: unknown }; + +type CommandVerb = 'open' | 'close' | 'navigate' | 'hydrateContext' | 'scroll'; diff --git a/packages/app-bridge-theme/src/registries/EventRegistry.ts b/packages/app-bridge-theme/src/registries/EventRegistry.ts new file mode 100644 index 000000000..3f541f39d --- /dev/null +++ b/packages/app-bridge-theme/src/registries/EventRegistry.ts @@ -0,0 +1,7 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type Asset, type EventNameValidator } from '../types'; + +export type EventRegistry = EventNameValidator<{ + assetsChosen: { assets: Asset[] }; +}>; diff --git a/packages/app-bridge-theme/src/registries/commands/AiBrandAssistantDialog.ts b/packages/app-bridge-theme/src/registries/commands/AiBrandAssistantDialog.ts new file mode 100644 index 000000000..d812a1ded --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/AiBrandAssistantDialog.ts @@ -0,0 +1,14 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const openAiBrandAssistantDialog = (): DispatchHandlerParameter< + 'openAiBrandAssistantDialog', + CommandRegistry +> => ({ name: 'openAiBrandAssistantDialog' }); + +export const closeAiBrandAssistantDialog = (): DispatchHandlerParameter< + 'closeAiBrandAssistantDialog', + CommandRegistry +> => ({ name: 'closeAiBrandAssistantDialog' }); diff --git a/packages/app-bridge-theme/src/registries/commands/HydrateContextDocumentNavigation.ts b/packages/app-bridge-theme/src/registries/commands/HydrateContextDocumentNavigation.ts new file mode 100644 index 000000000..932b64904 --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/HydrateContextDocumentNavigation.ts @@ -0,0 +1,11 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const hydrateContextDocumentNavigation = ( + documentId: CommandRegistry['hydrateContextDocumentNavigation'], +): DispatchHandlerParameter<'hydrateContextDocumentNavigation', CommandRegistry> => ({ + name: 'hydrateContextDocumentNavigation', + payload: documentId, +}); diff --git a/packages/app-bridge-theme/src/registries/commands/Navigate.ts b/packages/app-bridge-theme/src/registries/commands/Navigate.ts new file mode 100644 index 000000000..e2b0333fa --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/Navigate.ts @@ -0,0 +1,9 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const navigate = (path: CommandRegistry['navigate']): DispatchHandlerParameter<'navigate', CommandRegistry> => ({ + name: 'navigate', + payload: path, +}); diff --git a/packages/app-bridge-theme/src/registries/commands/NavigateToSectionHeading.ts b/packages/app-bridge-theme/src/registries/commands/NavigateToSectionHeading.ts new file mode 100644 index 000000000..901ae7bd5 --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/NavigateToSectionHeading.ts @@ -0,0 +1,11 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const navigateToSectionHeading = ( + sectionId: CommandRegistry['navigateToSectionHeading'], +): DispatchHandlerParameter<'navigateToSectionHeading', CommandRegistry> => ({ + name: 'navigateToSectionHeading', + payload: sectionId, +}); diff --git a/packages/app-bridge-theme/src/registries/commands/ScrollPageToTop.ts b/packages/app-bridge-theme/src/registries/commands/ScrollPageToTop.ts new file mode 100644 index 000000000..577b5959d --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/ScrollPageToTop.ts @@ -0,0 +1,8 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const scrollPageToTop = (): DispatchHandlerParameter<'scrollPageToTop', CommandRegistry> => ({ + name: 'scrollPageToTop', +}); diff --git a/packages/app-bridge-theme/src/registries/commands/SearchDialog.ts b/packages/app-bridge-theme/src/registries/commands/SearchDialog.ts new file mode 100644 index 000000000..ec4adbb07 --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/SearchDialog.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type DispatchHandlerParameter } from '../../types'; +import { type CommandRegistry } from '../CommandRegistry'; + +export const openSearchDialog = (): DispatchHandlerParameter<'openSearchDialog', CommandRegistry> => ({ + name: 'openSearchDialog', +}); + +export const closeSearchDialog = (): DispatchHandlerParameter<'closeSearchDialog', CommandRegistry> => ({ + name: 'closeSearchDialog', +}); diff --git a/packages/app-bridge-theme/src/registries/commands/index.ts b/packages/app-bridge-theme/src/registries/commands/index.ts new file mode 100644 index 000000000..011980b4c --- /dev/null +++ b/packages/app-bridge-theme/src/registries/commands/index.ts @@ -0,0 +1,8 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export * from './AiBrandAssistantDialog'; +export * from './SearchDialog'; +export * from './Navigate'; +export * from './NavigateToSectionHeading'; +export * from './HydrateContextDocumentNavigation'; +export * from './ScrollPageToTop'; diff --git a/packages/app-bridge-theme/src/registries/index.ts b/packages/app-bridge-theme/src/registries/index.ts new file mode 100644 index 000000000..983ed724d --- /dev/null +++ b/packages/app-bridge-theme/src/registries/index.ts @@ -0,0 +1,5 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export * from './CommandRegistry'; +export * from './EventRegistry'; +export * from './commands'; diff --git a/packages/app-bridge-theme/src/tests/setupTests.ts b/packages/app-bridge-theme/src/tests/setupTests.ts new file mode 100644 index 000000000..45eb936cf --- /dev/null +++ b/packages/app-bridge-theme/src/tests/setupTests.ts @@ -0,0 +1,8 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { configure } from '@testing-library/react'; +import { beforeAll } from 'vitest'; + +beforeAll(() => { + configure({ testIdAttribute: 'data-test-id' }); +}); diff --git a/packages/app-bridge-theme/src/types/Asset.ts b/packages/app-bridge-theme/src/types/Asset.ts new file mode 100644 index 000000000..385c92f15 --- /dev/null +++ b/packages/app-bridge-theme/src/types/Asset.ts @@ -0,0 +1,26 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export type Asset = { + id: number; + creatorName: string; + extension: string; + externalUrl: Nullable; + fileName: string; + title: string; + status: string; + objectType: string; + height: Nullable; + width: Nullable; + genericUrl: string; + previewUrl: string; + originUrl: string; + projectId: number; + fileSize: number; + fileSizeHumanReadable: string; + fileId: string; + token: string; + projectType: Nullable; + revisionId: Nullable; + backgroundColor: Nullable; + isDownloadProtected: boolean; +}; diff --git a/packages/app-bridge-theme/src/types/Command.ts b/packages/app-bridge-theme/src/types/Command.ts new file mode 100644 index 000000000..9f9bcf78d --- /dev/null +++ b/packages/app-bridge-theme/src/types/Command.ts @@ -0,0 +1,17 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type CommandRegistry } from '../registries'; + +import { type WrongNamePattern } from './Common'; + +type DispatchHandler< + CommandName extends keyof CommandRegistry, + TCommand extends CommandRegistry, +> = TCommand[CommandName] extends void ? { name: CommandName } : { name: CommandName; payload: TCommand[CommandName] }; + +export type DispatchHandlerParameter< + CommandName, + TCommand extends CommandRegistry, +> = CommandName extends keyof CommandRegistry + ? DispatchHandler + : WrongNamePattern; diff --git a/packages/app-bridge-theme/src/types/Common.ts b/packages/app-bridge-theme/src/types/Common.ts new file mode 100644 index 000000000..69da2781b --- /dev/null +++ b/packages/app-bridge-theme/src/types/Common.ts @@ -0,0 +1,17 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +type NameContextList = 'Command' | 'Event'; +export type WrongNamePattern = ApiMethodName extends string + ? `The following ${NameContext} do not match the naming pattern: ${ApiMethodName}` + : never; + +export type ObjectNameValidator< + NameObject, + PatternObject, + NameContext extends NameContextList, +> = keyof NameObject extends keyof PatternObject + ? NameObject + : WrongNamePattern< + `${Exclude, Extract>}`, + NameContext + >; diff --git a/packages/app-bridge-theme/src/types/Context.ts b/packages/app-bridge-theme/src/types/Context.ts new file mode 100644 index 000000000..e652e7269 --- /dev/null +++ b/packages/app-bridge-theme/src/types/Context.ts @@ -0,0 +1,72 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type Asset } from './Asset'; +import { type EventUnsubscribeFunction } from './Event'; +import { + type PortalNavigationItem, + type BrandPortalLink, + type CoverPage, + type Document, + type DocumentLibrary, + type DocumentPage, + type DocumentChildNavigationItem, +} from './Guideline'; +import { type Language } from './Language'; +import { type ThemeTemplate } from './ThemeTemplate'; + +export type TemplateContext = { templateId: string; type: ThemeTemplate } & ( + | { type: 'documentPage'; document: Document; documentPage: DocumentPage } + | { type: 'library'; document: DocumentLibrary } + | { type: 'cover'; coverPage: CoverPage } +); + +export type Context = { + brandPortalLink: BrandPortalLink | null; + portalId: number; + portalNavigation: PortalNavigationItem[] | null; + documentNavigation: Record; + currentLanguage: string; + defaultLanguage: string; + enabledFeatures: string[]; + isEditing: boolean; + isPublicLink: boolean; + isAuthenticated: boolean; + isAiBrandAssistantDialogOpen: boolean; + isSearchDialogOpen: boolean; + activeSectionHeadingId: number | null; + scrollableAreaAttributes: { + scrollTop: number; + } | null; + languages: Language[]; + template: TemplateContext | null; + settings: { + templateSettings: Record; + templateAssets: Record; + }; +}; + +export type ContextReturn = Key extends keyof Context + ? { + /** + * Gets the current value of the context object at the given key. + */ + get(): Readonly; + /** + * Subscribes to changes in the context object at the given key. + */ + subscribe( + callbackFunction: (nextContext: Context[Key], previousContext: Context[Key]) => void, + ): EventUnsubscribeFunction; + } + : { + /** + * Gets the current value of the context object. + */ + get(): Readonly; + /** + * Subscribes to changes in the context object. + */ + subscribe( + callbackFunction: (nextContext: Context, previousContext: Context) => void, + ): EventUnsubscribeFunction; + }; diff --git a/packages/app-bridge-theme/src/types/Event.ts b/packages/app-bridge-theme/src/types/Event.ts new file mode 100644 index 000000000..917b74828 --- /dev/null +++ b/packages/app-bridge-theme/src/types/Event.ts @@ -0,0 +1,64 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type Simplify } from 'type-fest'; + +import { type EventRegistry } from '../registries'; + +import { type ObjectNameValidator, type WrongNamePattern } from './Common'; +import { type Context } from './Context'; +import { type State } from './State'; + +type EventVerb = 'chosen'; + +export type EventNameValidator = Simplify< + ObjectNameValidator +>; + +type ContextAsEventName = { + [ContextKey in keyof Context as ContextKey extends string ? `Context.${ContextKey}` : never]: [ + Context[ContextKey], + Context[ContextKey], + ]; +}; + +type StateAsEventName = { + [StateKey in keyof State as StateKey extends string ? `State.${StateKey}` : never]: [ + State[StateKey], + State[StateKey], + ]; +}; + +export type AppBridgeThemeEvent = EventNameValidator< + Pick & + ContextAsEventName< + Context & { + '*': Context; + } + > & + StateAsEventName< + State & { + '*': State; + } + > +>; + +export type EventNamePattern = { + [eventName: `Context.${string}` | `State.${string}` | `${string}${Capitalize}`]: unknown; +}; +export type EventNameParameter< + EventName, + EventNameParameter extends EventNamePattern, +> = EventName extends keyof EventNameParameter ? EventName : WrongNamePattern; + +export type EventCallbackParameter = EventName extends keyof AppBridgeThemeEvent + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + AppBridgeThemeEvent[EventName] extends any[] + ? (...eventReturn: AppBridgeThemeEvent[EventName]) => void + : (eventReturn: AppBridgeThemeEvent[EventName]) => void + : () => void; + +export type EventUnsubscribeFunction = () => void; + +export type SubscribeMap = { + [EventName in keyof Event as EventName]: Map, boolean>; +}; diff --git a/packages/app-bridge-theme/src/types/Guideline.ts b/packages/app-bridge-theme/src/types/Guideline.ts new file mode 100644 index 000000000..4aceb0944 --- /dev/null +++ b/packages/app-bridge-theme/src/types/Guideline.ts @@ -0,0 +1,139 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export enum LinkSettingsDisplay { + TextAndIcon = 'ICON_TEXT', + IconOnly = 'ICON', + TextOnly = 'TEXT', +} + +export enum LinkSettingsIconPosition { + Right = 'RIGHT', + Left = 'LEFT', +} + +interface CoverPageBase { + id(): number; + title(language?: string): string; + isPublished(): boolean; + isHiddenInNavigation(): boolean; + url(language?: string): string; + type: 'cover-page'; +} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CoverPageNavigationItem extends CoverPageBase {} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CoverPage extends CoverPageBase {} + +export interface DocumentGroupNavigationItem { + id(): number; + title(language?: string): string; + children(): (DocumentNavigationItem | DocumentLibraryNavigationItem | DocumentLinkNavigationItem)[]; + type: 'document-group'; +} + +interface DocumentBase { + id(): number; + title(language?: string): string; + slug(language?: string): string; + url(language?: string): string; + type: 'document'; +} +export interface DocumentNavigationItem extends DocumentBase { + parentId(): Nullable; +} +export interface Document extends DocumentBase { + documentGroupId(): Nullable; +} + +interface DocumentLibraryBase { + id(): number; + title(language?: string): string; + slug(language?: string): string; + url(language?: string): string; + type: 'document-library'; +} +export interface DocumentLibraryNavigationItem extends DocumentLibraryBase { + parentId(): Nullable; +} +export interface DocumentLibrary extends DocumentLibraryBase { + documentGroupId(): Nullable; +} + +export interface DocumentLinkNavigationItem { + id(): number; + title(language?: string): string; + url(): string; + displayMode(): LinkSettingsDisplay; + iconPosition(): LinkSettingsIconPosition; + customIconUrl(): Nullable; + shouldOpenInNewTab(): boolean; + parentId(): Nullable; + type: 'document-link'; +} + +export interface PageCategoryNavigationItem { + id(): number; + title(language?: string): string; + slug(language?: string): string; + children(): (DocumentPageNavigationItem | DocumentPageLinkNavigationItem)[]; + type: 'page-category'; +} + +interface DocumentPageBase { + id(): number; + isPublished(): boolean; + title(language?: string): string; + slug(language?: string): string; + url(language?: string): string; + type: 'document-page'; +} +export interface DocumentPageNavigationItem extends DocumentPageBase { + headings(): DocumentPageHeadingNavigationItem[]; +} + +export interface AdjacentPage { + title(language?: string): string; + categoryTitle(language?: string): Nullable; + documentTitle(language?: string): string; + url(language?: string): string; +} + +export interface DocumentPage extends DocumentPageBase { + categoryId(): Nullable; + documentId(): number; + previousPage(): Nullable; + nextPage(): Nullable; + lastModified(): Date; +} + +export interface DocumentPageLinkNavigationItem { + id(): number; + title(language?: string): string; + url(): string; + type: 'document-page-link'; +} + +export interface DocumentPageHeadingNavigationItem { + id(): number; + title(language?: string): string; + slug(language?: string): string; + type: 'document-page-heading'; +} + +export interface BrandPortalLink { + isEnabled(): boolean; + title(language?: string): string; + url(language?: string): string; +} + +export type PortalNavigationItem = + | CoverPageNavigationItem + | DocumentGroupNavigationItem + | DocumentNavigationItem + | DocumentLibraryNavigationItem + | DocumentLinkNavigationItem; + +export type DocumentChildNavigationItem = + | PageCategoryNavigationItem + | DocumentPageNavigationItem + | DocumentPageLinkNavigationItem; diff --git a/packages/app-bridge-theme/src/types/GuidelineSearchResult.ts b/packages/app-bridge-theme/src/types/GuidelineSearchResult.ts new file mode 100644 index 000000000..5bfd27ec1 --- /dev/null +++ b/packages/app-bridge-theme/src/types/GuidelineSearchResult.ts @@ -0,0 +1,31 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export const GuidelineSearchResultTypeMap = { + block: 'BLOCK', + section: 'SECTION', + page: 'PAGE', + color: 'COLOR', +}; +type GuidelineSearchResultType = (typeof GuidelineSearchResultTypeMap)[keyof typeof GuidelineSearchResultTypeMap]; + +export type GuidelineSearchResult = { + highlights: string[]; + type: GuidelineSearchResultType; + objectId: number; + pageId: number; + pageSlug: string; + pageTitle: string; + pageCategorySlug: string | null; + blockId: number; + documentId: number; + documentSlug: string; + documentTitle: string; + portalId: number; + portalToken: string | null; + sectionId: string | null; + sectionSlug: string | null; + sectionTitle: string | null; + colorHex?: string; + projectColorId?: string; + guidelineTitle: string; +}; diff --git a/packages/app-bridge-theme/src/types/Language.ts b/packages/app-bridge-theme/src/types/Language.ts new file mode 100644 index 000000000..ea2838728 --- /dev/null +++ b/packages/app-bridge-theme/src/types/Language.ts @@ -0,0 +1,23 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export type Language = { + /** + * The language code in ISO 639-1 format. + */ + isoCode: string; + + /** + * The name of the language. + */ + name: string; + + /** + * Indicates whether the language is the default language. + */ + isDefault: boolean; + + /** + * Indicates whether the language is in draft state. + */ + isDraft: boolean; +}; diff --git a/packages/app-bridge-theme/src/types/State.ts b/packages/app-bridge-theme/src/types/State.ts new file mode 100644 index 000000000..335a6687d --- /dev/null +++ b/packages/app-bridge-theme/src/types/State.ts @@ -0,0 +1,42 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type EventUnsubscribeFunction } from './Event.ts'; + +export type State = { + /** + * The total height of any sticky elements at the top of the document in pixels. + */ + scrollTopOffset: number; +}; + +export type StateReturn = Key extends keyof State + ? { + /** + * Gets the current value of the state object at the given key. + */ + get(): Readonly; + /** + * Sets a new value of the state object at the given key. + */ + set(nextState: State[Key]): void; + /** + * Subscribes to changes in the state object at the given key. + */ + subscribe( + callbackFunction: (nextState: State[Key], previousState: State[Key]) => void, + ): EventUnsubscribeFunction; + } + : { + /** + * Gets the current value of the state object. + */ + get(): Readonly; + /** + * Sets a new value of the state object. + */ + set(nextState: State): void; + /** + * Subscribes to changes in the state object. + */ + subscribe(callbackFunction: (nextState: State, previousState: State) => void): EventUnsubscribeFunction; + }; diff --git a/packages/app-bridge-theme/src/types/ThemeTemplate.ts b/packages/app-bridge-theme/src/types/ThemeTemplate.ts new file mode 100644 index 000000000..1b3df14e9 --- /dev/null +++ b/packages/app-bridge-theme/src/types/ThemeTemplate.ts @@ -0,0 +1,3 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export type ThemeTemplate = 'documentPage' | 'cover' | 'library'; diff --git a/packages/app-bridge-theme/src/types/index.ts b/packages/app-bridge-theme/src/types/index.ts new file mode 100644 index 000000000..1860dce5e --- /dev/null +++ b/packages/app-bridge-theme/src/types/index.ts @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +export * from './Asset'; +export * from './Command'; +export * from './Common'; +export * from './Context'; +export * from './Event'; +export * from './Guideline'; +export * from './GuidelineSearchResult'; +export * from './Language'; +export * from './State'; +export * from './ThemeTemplate'; diff --git a/packages/app-bridge-theme/tsconfig.json b/packages/app-bridge-theme/tsconfig.json new file mode 100644 index 000000000..00bc359ff --- /dev/null +++ b/packages/app-bridge-theme/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "baseUrl": ".", + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "target": "ES2021", + "useDefineForClassFields": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "types": ["vite/client"] + }, + "include": ["src", "./tests/setupTests.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/app-bridge-theme/tsconfig.node.json b/packages/app-bridge-theme/tsconfig.node.json new file mode 100644 index 000000000..a06db87c4 --- /dev/null +++ b/packages/app-bridge-theme/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "composite": true, + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "target": "ESNext" + }, + "include": ["eslint.config.mjs", "vite.config.mts", "package.json"] +} diff --git a/packages/app-bridge-theme/vite.config.mts b/packages/app-bridge-theme/vite.config.mts new file mode 100644 index 000000000..1c48a5ddf --- /dev/null +++ b/packages/app-bridge-theme/vite.config.mts @@ -0,0 +1,44 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +import { dependencies as dependenciesMap, peerDependencies as peerDependenciesMap } from './package.json'; + +const dependencies = Object.keys(dependenciesMap); +const peerDependencies = Object.keys(peerDependenciesMap); + +export const globals = { + react: 'React', + 'react-dom': 'ReactDOM', +}; + +export default defineConfig({ + plugins: [dts({ insertTypesEntry: true, rollupTypes: true })], + build: { + lib: { + entry: { + index: './src/index.ts', + }, + name: 'app-bridge-theme', + }, + sourcemap: true, + minify: true, + rollupOptions: { + external: [...dependencies, ...peerDependencies], + }, + }, + test: { + environment: 'happy-dom', + css: true, + coverage: { + enabled: true, + provider: 'v8', + all: true, + reporter: ['text', 'lcov'], + include: ['src/**/*.ts', 'src/**/*.tsx'], + exclude: ['src/**/test.ts', 'src/**/test.tsx', 'src/**/spec.ts', 'src/**/spec.tsx'], + }, + setupFiles: ['./src/tests/setupTests.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a4d3fb3..e17d8d83d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,66 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(msw@1.3.5(@types/node@24.7.2)(typescript@5.9.2))(terser@5.31.1)(yaml@2.7.0) + packages/app-bridge-theme: + devDependencies: + '@frontify/eslint-config-react': + specifier: ^1.0.4 + version: 1.0.4(eslint@9.34.0(jiti@1.21.6))(prettier@3.6.2)(react@18.3.1)(ts-api-utils@2.1.0(typescript@5.9.2))(typescript@5.9.2) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@9.3.4)(@types/react-dom@18.3.7(@types/react@18.3.25))(@types/react@18.3.25)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.3.24 + version: 18.3.25 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.25) + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + '@vitest/ui': + specifier: ^1.4.0 + version: 1.6.1(vitest@3.2.4) + eslint: + specifier: ^9.34.0 + version: 9.34.0(jiti@1.21.6) + eslint-plugin-notice: + specifier: ^1.0.0 + version: 1.0.0(eslint@9.34.0(jiti@1.21.6)) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + ts-json-as-const: + specifier: ^1.0.7 + version: 1.0.7(typescript@5.9.2) + type-fest: + specifier: ^4.41.0 + version: 4.41.0 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.42.0 + version: 8.42.0(eslint@9.34.0(jiti@1.21.6))(typescript@5.9.2) + vite: + specifier: ^5.4.19 + version: 5.4.19(@types/node@24.7.2)(terser@5.31.1) + vite-plugin-dts: + specifier: ^3.9.1 + version: 3.9.1(@types/node@24.7.2)(rollup@4.52.3)(typescript@5.9.2)(vite@5.4.19(@types/node@24.7.2)(terser@5.31.1)) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@1.6.1)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0) + packages/cli: dependencies: '@fastify/cors': @@ -4346,11 +4406,19 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/ui@1.6.1': + resolution: {integrity: sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==} + peerDependencies: + vitest: 1.6.1 + '@vitest/ui@3.2.4': resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: vitest: 3.2.4 + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -5337,6 +5405,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -6016,9 +6088,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -6126,6 +6195,9 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -7047,6 +7119,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -7755,6 +7830,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -8489,6 +8567,10 @@ packages: sinon@21.0.0: resolution: {integrity: sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==} + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + sirv@3.0.1: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} @@ -12018,7 +12100,6 @@ snapshots: '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 - optional: true '@jest/types@26.6.2': dependencies: @@ -13969,8 +14050,7 @@ snapshots: '@sideway/pinpoint@2.0.0': optional: true - '@sinclair/typebox@0.27.8': - optional: true + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': dependencies: @@ -15031,7 +15111,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@18.19.123)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(msw@1.3.5(@types/node@24.7.2)(typescript@5.9.2))(terser@5.31.1)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -15089,6 +15169,17 @@ snapshots: dependencies: tinyspy: 4.0.3 + '@vitest/ui@1.6.1(vitest@3.2.4)': + dependencies: + '@vitest/utils': 1.6.1 + fast-glob: 3.3.3 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 1.1.2 + picocolors: 1.1.1 + sirv: 2.0.4 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@1.6.1)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0) + '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: '@vitest/utils': 3.2.4 @@ -15098,7 +15189,14 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(msw@1.3.5(@types/node@24.7.2)(typescript@5.9.2))(terser@5.31.1)(yaml@2.7.0) + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 '@vitest/utils@3.2.4': dependencies: @@ -16188,6 +16286,8 @@ snapshots: didyoumean@1.2.2: {} + diff-sequences@29.6.3: {} + diff@4.0.2: {} diff@5.2.0: {} @@ -17180,11 +17280,9 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.1 + flatted: 3.3.3 keyv: 4.5.4 - flatted@3.3.1: {} - flatted@3.3.3: {} flow-enums-runtime@0.0.6: @@ -17298,6 +17396,8 @@ snapshots: get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -18283,6 +18383,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + loupe@3.2.1: {} lru-cache@10.2.2: {} @@ -19309,6 +19413,8 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -19441,7 +19547,6 @@ snapshots: '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.3.1 - optional: true prismjs@1.29.0: {} @@ -20209,6 +20314,12 @@ snapshots: diff: 7.0.0 supports-color: 7.2.0 + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.25 + mrmime: 2.0.0 + totalist: 3.0.1 + sirv@3.0.1: dependencies: '@polka/url': 1.0.0-next.25 @@ -21385,7 +21496,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(msw@1.3.5(@types/node@24.7.2)(typescript@5.9.2))(terser@5.31.1)(yaml@2.7.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@1.6.1)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -21413,7 +21524,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.7.2 - '@vitest/ui': 3.2.4(vitest@3.2.4) + '@vitest/ui': 1.6.1(vitest@3.2.4) happy-dom: 18.0.1 transitivePeerDependencies: - jiti @@ -21429,7 +21540,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(terser@5.31.1)(yaml@2.7.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@1.21.6)(msw@1.3.5(@types/node@24.7.2)(typescript@5.9.2))(terser@5.31.1)(yaml@2.7.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4