diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..d85a277c43 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,327 @@ +# GitHub Copilot – PR Review Instructions for Mendix Web Widgets + +Use this guide to review both code and workflow. Focus on Mendix pluggable widget conventions, Atlas UI styling, React best practices, and our release process. + +## Repo context + +- **Monorepo** with packages under `packages/`: + - `packages/pluggableWidgets/*-web` + - `packages/modules/*` + - `packages/customWidgets/*` + - `packages/shared/*` (configs, plugins) +- **Stack**: TypeScript, React, SCSS, Rollup via `@mendix/pluggable-widgets-tools`, Jest/RTL, ESLint/Prettier. +- **Commands** (root): `pnpm lint`, `pnpm test`, `pnpm build`, `pnpm -w changelog`, `pnpm -w version`. + +## What to check on every PR + +### PR metadata and process + +- **Title format**: + - If JIRA: `[XX-000]: description` + - Else: conventional commits (e.g., `feat: ...`, `fix: ...`). +- **Template adherence** (see `.github/pull_request_template.md`): + - Lint/test locally: `pnpm lint`, `pnpm test`. + - New tests for features/bug fixes (unit tests in `src/**/__tests__/*.spec.ts`, E2E tests in `e2e/*.spec.js`). + - Related PRs linked if applicable. + - If XML or behavior changes: ask for docs PR link in `mendix/docs`. +- **Multi-package PRs**: Validate each changed package separately. + +### Versioning and changelog (per changed package) + +- If runtime code, public API, XML schema, or behavior changes in a package: + - **Require semver bump** in that package's `package.json`. + - **Require `CHANGELOG.md` update** (Keep a Changelog, semver). + - Suggest: `pnpm -w changelog` (or update manually). +- If refactor/docs/tests-only: bump not required (ask author to confirm). +- Multiple changed packages: each needs its own bump and changelog entry. + +## Code quality – Mendix pluggable widgets and React + +### Mendix-specific + +- **XML ↔ TSX alignment**: lowerCamelCase property keys; TS props/types updated with XML changes; unique widget ID; captions/descriptions/defaults valid. +- **Mendix data API**: + - Check `ActionValue.canExecute` before `execute()`. + - Use `EditableValue.setValue()` for two-way binding. + - Render loading/empty states until values are ready. + +### React code-logic best practices + +- **Hooks and effects** + + - Correct `useEffect`/`useMemo`/`useCallback` dependencies; avoid stale closures. + - No side effects in render. Cleanup subscriptions/timers on unmount. + - Guard async effects to avoid setting state after unmount: + ```ts + useEffect(() => { + let active = true; + (async () => { + const data = await load(); + if (active) setData(data); + })(); + return () => { + active = false; + }; + }, [load]); + ``` + - Avoid deriving state directly from props unless necessary; prefer computing from props or synchronize carefully (watch for loops). + +- **State management** + + - Use functional updates when reading previous state: + ```ts + setCount(c => c + 1); + ``` + - Avoid unnecessary state for values derived from props. Keep state minimal and source-of-truth clear (controlled vs uncontrolled). + - **MobX stores** for complex cross-component state; **React state** for simple UI state; **Mendix props** as source of truth for persistent data. + +- **Rendering and lists** + + - Use stable, unique `key`s (avoid array index unless list is static). + - Avoid heavy computations in render; memoize when there's proven benefit. + - For large lists/tables, consider virtualization. + +- **Performance hygiene** + + - Limit `useCallback`/`useMemo` to cases with measurable re-render cost; ensure dependency arrays are correct. + - Avoid creating new objects/arrays/styles inline when passed to children repeatedly; memoize where needed. + +- **Composition and props** + + - Prefer composition over prop drilling; consider Context when appropriate. + - Don't spread unknown props onto DOM nodes (avoid React unknown prop warnings). Validate/filter props before spreading. + +- **Accessibility** + + - Semantic elements, proper ARIA, focus management, and keyboard navigation. + +- **Error handling and robustness** + + - Handle null/undefined from Mendix props; safe optional chaining; avoid non-null assertions unless justified. + - Guard external data parsing; provide graceful fallbacks. + - Avoid `dangerouslySetInnerHTML`; if unavoidable, sanitize input. + +- **MobX best practices** + - Use `makeAutoObservable(this)` or `makeObservable(this, { ... })` in store constructors. + - Mark state mutations as `action`; use `computed` for derived values; avoid side effects in computed. + - Use `reaction()` for side effects, not `autorun()` in most cases. + - Integrate with React via `useSubscribe()` hook from `@mendix/widget-plugin-mobx-kit`. + - Keep stores focused and avoid deep nesting; prefer composition over inheritance. + +### Styling and theming + +- Use **SCSS**; avoid inline styles for static design. +- Prefer **Atlas UI classes** (e.g., `btn`, `badge`); do not override core Atlas classes. +- Prefix custom classes with widget name; BEM-like naming. +- Responsive: relative units/media queries; avoid `!important`. + +### Project/tooling consistency + +- Follow ESLint/Prettier; do not change existing indentation style. +- For new deps: justify need/size; prefer tree-shakable imports; respect root `pnpm` versions/overrides. +- Don't modify generated bundles/patch files unless intended. + +## Heuristics Copilot should apply + +### Release/workflow checks + +- Code or XML changed in `packages/**//` but no version bump or `CHANGELOG.md`: + - "Behavior changed but no version bump or `CHANGELOG.md`. Please bump semver and add changelog (you can use `pnpm -w changelog`)." +- Features/bug fixes without tests: + - "Please add/adjust unit tests in `src/components/__tests__/` or component tests to cover this change. For user-facing features, consider adding E2E tests in `e2e/`." +- XML changes without TS alignment: + - "XML props changed but TS props/usage aren't aligned. Please update the component props/types and usage." + +### React logic checks + +- **Effect dependencies/stale closure**: + - If an effect references variables not in the dependency array, request adding them or restructuring. +- **Async effect race**: + - If an async effect sets state without a guard, suggest guarding or aborting as shown above. +- **Functional state updates**: + - If using `setX(x + 1)` with potential stale reads, suggest `setX(x => x + 1)`. +- **Derived state anti-pattern**: + - If `useState(props.someValue)` is used to mirror props without sync logic, suggest computing from props or explaining sync strategy. +- **List keys**: + - If `index` is used as `key` in dynamic lists, request a stable unique key (e.g., id). +- **Unnecessary memo/callback**: + - If `useMemo`/`useCallback` wraps cheap operations or has incorrect deps, suggest removing or fixing deps. +- **Inline allocations**: + - Repeated inline objects/arrays/styles passed to children: suggest memoization to reduce renders. +- **Controlled vs uncontrolled**: + - Inputs switching between `value` and `defaultValue` or missing `onChange` with `value`: flag and ask to make it consistently controlled or uncontrolled. +- **Unknown DOM props**: + - Spreading arbitrary props to DOM nodes: ask to filter out non-standard props. + +### MobX logic checks + +- **Store setup**: + - If a class has observable state but no `makeObservable`/`makeAutoObservable`, request adding it. +- **Action boundaries**: + - If state is mutated outside `action`, suggest wrapping in `action` or `runInAction`. +- **Computed purity**: + - If `computed` properties have side effects or mutations, suggest moving to `reaction` or regular methods. +- **React integration**: + - If MobX stores are used without `observer` HOC or `useSubscribe` hook, request proper React integration. +- **Store architecture**: + - If stores are deeply nested or overly complex, suggest breaking into focused, composable stores. + +### Styling/scroll behavior + +- Prefer root-cause layout/size fixes instead of programmatic scroll resets. + +## Testing requirements and best practices + +### Testing strategy overview + +This repository uses a comprehensive three-tier testing strategy: + +1. **Unit tests** (Jest + React Testing Library) - Test individual components and functions in isolation +2. **Component tests** - Test React components with Mendix data integration and user interactions +3. **E2E tests** (Playwright) - Test complete user workflows in real Mendix applications + +### Unit testing (Jest + RTL) + +- **Location**: `src/components/__tests__/*.spec.ts` or `src/__tests__/*.spec.ts` +- **Tools**: Jest, React Testing Library (enzyme-free configuration), `@mendix/widget-plugin-test-utils` +- **Config**: Each package uses `@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js` +- **Command**: `pnpm test` (package-level) or `pnpm -w test` (workspace-level) + +#### Unit test requirements + +- **New features**: Must include unit tests covering all logic branches and edge cases +- **Bug fixes**: Add regression tests that would have caught the original bug +- **Component props**: Test all prop combinations, especially error states and loading states +- **Mendix data handling**: Mock Mendix APIs using builders from `@mendix/widget-plugin-test-utils`: + + ```ts + import { dynamicValue, EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; + + const mockValue = new EditableValueBuilder().withValue("test").build(); + ``` + +- **Error boundaries**: Test error states and graceful fallbacks +- **Accessibility**: Include basic a11y assertions (roles, labels, ARIA attributes) +- **Snapshot tests**: Use sparingly, only for complex DOM structures that are unlikely to change + +#### Unit test patterns to review + +- **Test file naming**: Must follow `*.spec.ts` convention +- **Test descriptions**: Clear, behavior-focused descriptions ("renders loading state when data is unavailable") +- **Mocking strategy**: Prefer `@mendix/widget-plugin-test-utils` builders over manual mocks +- **Async testing**: Proper use of `waitFor`, `findBy*` queries for async operations +- **Cleanup**: Ensure tests don't leak state between runs + +### Component testing + +- **Scope**: Test React components integrated with Mendix data layer +- **Focus**: User interactions, data binding, prop changes, widget lifecycle +- **Tools**: Same as unit tests but with full Mendix context and data sources + +#### Component test requirements + +- **Mendix data integration**: Test with various Mendix data states (loading, empty, error, success) +- **User interactions**: Test clicks, form submissions, keyboard navigation +- **Widget lifecycle**: Test component mount/unmount, prop updates, re-renders +- **Data mutations**: Test `EditableValue.setValue()`, `ActionValue.execute()` calls +- **Validation**: Test form validation, error messages, required field handling + +### E2E testing (Playwright) + +- **Location**: `e2e/*.spec.js` in each widget package +- **Tools**: Playwright with custom Mendix test project setup via `automation/run-e2e` +- **Config**: `automation/run-e2e/playwright.config.cjs` +- **Commands**: + - `pnpm e2edev` - Development mode with GUI debugger + - `pnpm e2e` - CI mode (headless) + +#### E2E test requirements + +- **Complete workflows**: Test end-to-end user journeys, not just individual widgets +- **Cross-browser**: Tests run in Chromium (CI extends to other browsers) +- **Visual regression**: Use `toHaveScreenshot()` for visual consistency +- **Data scenarios**: Test with various data configurations from test projects +- **Accessibility**: Include `@axe-core/playwright` accessibility scans +- **Session cleanup**: Always cleanup Mendix sessions to avoid license limits: + ```js + test.afterEach("Cleanup session", async ({ page }) => { + await page.evaluate(() => window.mx.session.logout()); + }); + ``` + +#### E2E test structure + +- **Test project**: Each widget has dedicated test project in GitHub (`testProject.githubUrl`, `testProject.branchName`) +- **Page setup**: Use `page.goto("/")` and `page.waitForLoadState("networkidle")` +- **Selectors**: Prefer `mx-name-*` class selectors for Mendix widgets +- **Assertions**: Combine element visibility, screenshot comparisons, and content verification + +### Testing coverage expectations + +#### For new features + +- **Unit tests**: 80%+ code coverage, all public methods and edge cases +- **Component tests**: Key user interactions and data integration points +- **E2E tests**: At least one happy path and one error scenario + +#### For bug fixes + +- **Regression test**: Unit or component test that reproduces the original bug +- **Fix verification**: Test that confirms the fix works correctly +- **Edge case coverage**: Additional tests for similar potential issues + +#### For refactoring + +- **Test preservation**: All existing tests should continue to pass +- **Test updates**: Update tests if public APIs change, but avoid unnecessary changes +- **Coverage maintenance**: Code coverage should not decrease + +### Test-related heuristics for Copilot + +#### Missing test coverage + +- New React components without corresponding `.spec.ts` files: + - "New component `ComponentName` is missing unit tests. Please add tests in `src/components/__tests__/ComponentName.spec.ts`." +- Features affecting user workflows without E2E tests: + - "This feature changes user interaction patterns. Please add E2E tests in `e2e/WidgetName.spec.js` or update existing ones." +- Bug fixes without regression tests: + - "Bug fix detected but no regression test found. Please add a test that would have caught this issue." + +#### Test quality issues + +- Tests using deprecated Enzyme patterns: + - "Please migrate from Enzyme to React Testing Library using `render()` and `screen` queries." +- Hard-coded test data instead of builders: + - "Consider using `@mendix/widget-plugin-test-utils` builders instead of hardcoded mocks for better maintainability." +- E2E tests without session cleanup: + - "E2E tests must include session cleanup to avoid Mendix license limit issues. Add `test.afterEach()` with logout." +- Snapshot tests for dynamic content: + - "Avoid snapshot tests for dynamic content. Use specific assertions instead." + +#### Test configuration issues + +- Custom Jest config without extending base config: + - "Widget Jest config should extend `@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js`." +- Missing test project configuration for E2E: + - "Widget package.json missing `testProject.githubUrl` and `testProject.branchName` for E2E tests." +- E2E specs not following naming convention: + - "E2E test files should follow `WidgetName.spec.js` naming convention in `e2e/` directory." + +## Scope/Noise reduction + +- Focus on: `src/**`, `*.xml`, `*.scss`, `package.json`, `CHANGELOG.md`, build/test config changes. +- Generally ignore: `dist/**`, lockfile-only churn, generated files. + +## Quick commands + +- Lint: `pnpm lint` +- Test: `pnpm test` (unit tests) +- Build: `pnpm build` +- E2E (dev): `pnpm e2edev` (with GUI debugger) +- E2E (CI): `pnpm e2e` (headless) +- Prepare changelog/version (workspace): `pnpm -w changelog`, `pnpm -w version` + +## Tone and format for comments + +- Be specific and actionable; reference files/lines. +- Prefer small, concrete suggestions; include short code snippets when helpful. diff --git a/.github/renovate.json b/.github/renovate.json index 5353d55fda..749c30396d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -5,6 +5,7 @@ "labels": ["dependencies"], "rebaseLabel": "renovate-rebase", "branchPrefix": "deps/", + "branchNameStrict": true, "prHourlyLimit": 5, "prConcurrentLimit": 5, "prCreation": "immediate", @@ -25,7 +26,8 @@ }, "allowScripts": true, "ignoreScripts": false, - "ignoreDeps": ["typescript"], + "ignoreDeps": ["typescript", "react", "react-dom", "@types/react", "@types/react-dom"], + "ignorePaths": ["**/customWidgets/**"], "packageRules": [ { "matchCategories": ["docker"], "enabled": false }, { "matchPackagePatterns": ["*"], "rangeStrategy": "bump" }, diff --git a/.github/workflows/BuildJobs.yml b/.github/workflows/BuildJobs.yml index 15634fbb81..ede2cec658 100644 --- a/.github/workflows/BuildJobs.yml +++ b/.github/workflows/BuildJobs.yml @@ -98,7 +98,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - target: [build, release] + target: [release] steps: - name: Checkout @@ -125,16 +125,10 @@ jobs: key: turbo-cache-${{ runner.os }}-${{ matrix.target }}-${{ env.main_sha }} restore-keys: | turbo-cache-${{ runner.os }}-${{ matrix.target }} - - if: runner.os == 'Windows' - name: Set concurrency on Windows - run: echo "task_concurrency=3" >> $env:GITHUB_ENV - - if: runner.os == 'Linux' - name: Set concurrency on Linux - run: echo "task_concurrency=5" >> $GITHUB_ENV - name: Install dependencies run: pnpm install - name: Run ${{ matrix.target }} task - run: pnpm run ${{ matrix.target }} --concurrency=${{ env.task_concurrency }} ${{ env.since_flag }} + run: pnpm run ${{ matrix.target }} --concurrency=1 ${{ env.since_flag }} env: # Limit memory to avoid out of memory issues NODE_OPTIONS: "--max-old-space-size=5120 --max_old_space_size=5120" @@ -211,10 +205,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: >- - node ./automation/run-e2e/bin/run-e2e-in-chunks.mjs - --chunks ${{ matrix.chunks }} - --index ${{ matrix.index }} - --event-name ${{ github.event_name }} + node ./automation/run-e2e/bin/run-e2e-in-chunks.mjs --chunks ${{ matrix.chunks }} --index ${{ matrix.index }} --event-name ${{ github.event_name }} - name: Check file existence id: check_files uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 diff --git a/.github/workflows/PublishMarketplace.yml b/.github/workflows/PublishMarketplace.yml index 04e603a57c..62aaf5d923 100644 --- a/.github/workflows/PublishMarketplace.yml +++ b/.github/workflows/PublishMarketplace.yml @@ -4,12 +4,17 @@ on: release: types: [published] + workflow_dispatch: + inputs: + package: + description: "Release tag (e.g. data-widgets-v1.2.3)" + required: true jobs: publish-new-version: name: "Publish a new package version from GitHub release" runs-on: ubuntu-latest env: - TAG: ${{ github.ref_name }} + TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.package }} steps: - name: Check release tag @@ -21,7 +26,7 @@ jobs: exit 1 fi - name: "Set PACKAGE env var" - run: echo "PACKAGE=${TAG%-v*}" >> $GITHUB_ENV + run: echo "PACKAGE=@mendix/${TAG%-v*}" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -49,3 +54,4 @@ jobs: CPAPI_USER: ${{ secrets.CPAPI_USER }} CPAPI_USER_OPENID: ${{ secrets.SRV_WIDGETS_OPENID }} CPAPI_PASS: ${{ secrets.CPAPI_PASS }} + GH_PAT: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/RunE2ERCNightlies.yml b/.github/workflows/RunE2ERCNightlies.yml index 24d8c65b69..419bb342cc 100644 --- a/.github/workflows/RunE2ERCNightlies.yml +++ b/.github/workflows/RunE2ERCNightlies.yml @@ -27,7 +27,7 @@ jobs: - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps chromium - name: Setup AWS credentials - uses: aws-actions/configure-aws-credentials@b92d0d98bf51f5ecb0efd640be026492e58b5c36 + uses: aws-actions/configure-aws-credentials@3bb878b6ab43ba8717918141cd07a0ea68cfe7ea with: role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} diff --git a/.gitignore b/.gitignore index a4be81395c..70104cb4d9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ mendixProject tests .turbo automation/run-e2e/ctrf/*.json +.cursor +.windsurf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..2b77c77fc7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# Mendix Web Widgets Repository - AI Agent Overview + +This document provides a comprehensive overview of the Mendix Web Widgets repository structure and conventions for AI development assistants. This repository contains the official collection of pluggable web widgets for the Mendix low-code platform. + +## Repository Overview + +The **Mendix Web Widgets** repository is the official home of all Mendix platform-supported pluggable web widgets and related modules. These are reusable UI components built with modern web technologies (React, TypeScript, SCSS) that integrate seamlessly into Mendix Studio Pro applications. + +### Key Characteristics +- **Monorepo structure** using pnpm workspaces and Turbo for build orchestration +- **Modern web stack**: TypeScript, React, SCSS, Jest, ESLint, Prettier +- **Mendix integration**: Built using Mendix Pluggable Widgets API +- **Atlas UI alignment**: Consistent with Mendix's design system +- **Comprehensive testing**: Unit tests (Jest/RTL), E2E tests (Playwright) + +## Repository Structure + +``` +├── packages/ +│ ├── pluggableWidgets/ # Main widget packages (*-web folders) +│ ├── modules/ # Mendix modules +│ ├── customWidgets/ # Custom widget implementations +│ └── shared/ # Shared configurations and utilities +├── docs/ +│ └── requirements/ # Detailed technical requirements (see below) +├── automation/ # Build and release automation +└── .github/ # GitHub workflows and Copilot instructions +``` + +## Detailed Requirements Documentation + +The `/docs/requirements/` folder contains comprehensive technical documentation for understanding and contributing to this repository. Each document covers specific aspects of the development process: + +### Core Requirements and Guidelines + +- **[Project Requirements Document](docs/requirements/project-requirements-document.md)** - High-level overview of repository purpose, goals, target users, and design system alignment +- **[Technology Stack and Project Structure](docs/requirements/tech-stack.md)** - Core technologies, monorepo structure, configuration standards, and development tools +- **[Frontend Guidelines](docs/requirements/frontend-guidelines.md)** - CSS/SCSS styling guidelines, naming conventions, component best practices, and Atlas UI integration + +### Development Workflow and Integration + +- **[Application Flow and Widget Lifecycle](docs/requirements/app-flow.md)** - Complete widget development lifecycle from scaffolding to Studio Pro integration +- **[Backend Structure and Data Flow](docs/requirements/backend-structure.md)** - Widget-to-Mendix runtime integration, data handling, and event management +- **[Implementation Plan](docs/requirements/implementation-plan.md)** - Step-by-step guide for creating new widgets, including PR templates and testing requirements + +### Module Development + +- **[Widget to Module Conversion](docs/requirements/widget-to-module.md)** - Guidelines for converting widgets to Mendix modules when appropriate + +## Development Commands + +Key commands for working with this repository: + +- **`pnpm lint`** - Run linting across all packages +- **`pnpm test`** - Run unit tests across all packages +- **`pnpm build`** - Build all packages +- **`pnpm -w changelog`** - Update changelogs +- **`pnpm -w version`** - Bump versions across packages + +## AI Development Assistant Context + +### For Code Reviews and PR Analysis +See [.github/copilot-instructions.md](.github/copilot-instructions.md) for detailed PR review guidelines, including: +- Mendix-specific conventions and API usage +- React best practices and performance considerations +- Testing requirements (unit, component, E2E) +- Styling guidelines and Atlas UI integration +- Version management and changelog requirements + +### For Code Development +When working on this repository, prioritize: + +1. **Minimal changes** - Make surgical, precise modifications +2. **Mendix conventions** - Follow established patterns for XML configuration, TypeScript props, and data handling +3. **Testing coverage** - Ensure unit tests, component tests, and E2E tests as appropriate +4. **Atlas UI consistency** - Use Atlas classes and design tokens +5. **Performance** - Consider React render optimization and Mendix data efficiency + +### Common Development Patterns + +- **Widget Structure**: Each widget has XML configuration, TypeScript component, SCSS styling, and test files +- **Data Integration**: Use Mendix API objects (EditableValue, ActionValue, ListValue) correctly +- **Styling**: Prefer Atlas UI classes over custom styles; use SCSS for widget-specific styling +- **Testing**: Follow Jest + RTL for unit tests, Playwright for E2E testing + +## Getting Started + +1. **Prerequisites**: Node.js ≥22, pnpm 10.15.0 +2. **Installation**: `pnpm install` +3. **Development**: Set `MX_PROJECT_PATH` environment variable to your Mendix project +4. **Building**: Use `pnpm build` or `pnpm start` for development builds +5. **Testing**: Use `pnpm test` for unit tests, `pnpm e2e` for E2E tests + +For detailed implementation guidance, refer to the specific requirement documents linked above. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 51dfbf50dc..8c705ebe13 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Apache License + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +186,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Mendix Technology BV + Copyright 2022 Mendix Technology B.V. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -198,4 +198,4 @@ Apache License distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/automation/run-e2e/lib/dev.mjs b/automation/run-e2e/lib/dev.mjs index bf6bbcf327..0a4b20e389 100644 --- a/automation/run-e2e/lib/dev.mjs +++ b/automation/run-e2e/lib/dev.mjs @@ -14,10 +14,12 @@ export async function dev() { const parseArgsOptions = { string: ["browser"], - boolean: ["with-preps"], + boolean: ["with-preps", "update-project", "setup-project"], default: { browser: "chromium", - "with-preps": false + "with-preps": false, + "update-project": true, + "setup-project": false }, configuration: { // https://github.com/yargs/yargs-parser#boolean-negation @@ -33,9 +35,11 @@ export async function dev() { process.env.PATH += `${delimiter}${packageBinariesPath}`; const options = parseArgs(process.argv.slice(2), parseArgsOptions); - if (options.withPreps) { + if (options.withPreps || options.setupProject) { // Download test project from github await setupTestProject(); + } + if (options.withPreps || options.updateProject) { // Run update project hook await updateTestProject(); @@ -51,21 +55,33 @@ export async function dev() { await enquirer.prompt({ type: "confirm", - name: "__ingore__", + name: "__ignore__", result: () => "continue", message: "Press Enter to continue" }); - } else { - console.log(c.yellow("Skip preparations")); } const url = process.env.URL ?? "http://127.0.0.1:8080"; console.log(c.cyan(`Make sure app is running on ${url}`)); - try { - await await200(url); - } catch { - throw new Error(`Can't reach app on ${url}`); + let appRunning = false; + + while (!appRunning) { + try { + await await200(url); + appRunning = true; + } catch { + const { retry } = await enquirer.prompt({ + type: "confirm", + name: "retry", + initial: true, + message: `Can't reach app on ${url}. Do you want to retry?` + }); + + if (!retry) { + throw new Error(`App is not running on ${url}. Exiting.`); + } + } } console.log(c.cyan("Launch Playwright")); diff --git a/automation/run-e2e/lib/update-test-project.mjs b/automation/run-e2e/lib/update-test-project.mjs index dc79644341..0f86ef0ccc 100644 --- a/automation/run-e2e/lib/update-test-project.mjs +++ b/automation/run-e2e/lib/update-test-project.mjs @@ -6,29 +6,17 @@ import { join, resolve } from "node:path"; import sh from "shelljs"; import * as config from "./config.mjs"; import { fetchGithubRestAPI, fetchWithReport, packageMeta, streamPipe, usetmp } from "./utils.mjs"; +import { atlasCoreReleaseUrl } from "./config.mjs"; const { cp, rm, mkdir, test } = sh; -async function getLatestReleaseByName(name, atlasCoreReleaseUrl) { - const releasesResponse = await fetchGithubRestAPI(`${atlasCoreReleaseUrl}`); - - if (!releasesResponse.ok) { - throw new Error("Can't fetch releases"); - } - - const releases = await releasesResponse.json(); - - if (!Array.isArray(releases)) { - throw new Error("Releases response is not an array"); +async function getReleaseByTag(tag) { + const url = `${atlasCoreReleaseUrl}/tags/${tag}`; + const response = await fetchGithubRestAPI(url); + if (!response.ok) { + throw new Error(`Can't fetch release for tag: ${tag}`); } - - const filteredReleases = releases.filter(release => release.name.toLowerCase().includes(name.toLowerCase())); - - if (filteredReleases.length === 0) { - throw new Error(`No releases found with name: ${name}`); - } - - return filteredReleases[0]; + return await response.json(); } async function downloadAndExtract(url, downloadPath, extractPath) { @@ -47,10 +35,7 @@ async function updateAtlasThemeSource() { rm("-rf", config.atlasDirsToRemove); - const release = await getLatestReleaseByName( - "Atlas Core - Marketplace Release v3.17.0", - config.atlasCoreReleaseUrl - ); + const release = await getReleaseByTag("atlas-core-v3.17.0"); const { browser_download_url } = release.assets[0]; const downloadedPath = join(await usetmp(), config.nameForDownloadedAtlasCore); const outPath = await usetmp(); @@ -64,6 +49,11 @@ async function updateAtlasThemeSource() { } cp("-r", themeSourcePath, config.testProjectDir); + + // Fix file permissions to ensure Docker can write to theme files + // The Atlas theme files are copied with read-only permissions + // but mxbuild needs to write to some generated files during build + sh.exec(`chmod -R +w "${config.testProjectDir}/themesource"`, { silent: true }); } async function updateAtlasTheme() { @@ -71,12 +61,12 @@ async function updateAtlasTheme() { rm("-rf", "tests/testProject/theme"); - const release = await getLatestReleaseByName("Atlas UI - Theme Folder Files", config.atlasCoreReleaseUrl); - - if (!release) { - throw new Error("Can't fetch latest Atlas UI theme release"); + // Fetch the specific release by tag from GitHub API + const tag = "atlasui-theme-files-2024-01-25"; + const release = await getReleaseByTag(tag); + if (!release.assets || release.assets.length === 0) { + throw new Error(`No assets found for release tag: ${tag}`); } - const [{ browser_download_url }] = release.assets; const downloadedPath = join(await usetmp(), config.nameForDownloadedAtlasTheme); const outPath = await usetmp(); diff --git a/automation/run-e2e/lib/utils.mjs b/automation/run-e2e/lib/utils.mjs index 387acd85e7..ec9c453fd5 100644 --- a/automation/run-e2e/lib/utils.mjs +++ b/automation/run-e2e/lib/utils.mjs @@ -39,14 +39,19 @@ export async function fetchGithubRestAPI(url, init = {}) { export async function await200(url = "http://127.0.0.1:8080", attempts = 50) { let n = 0; - while (++n <= attempts) { - console.log(c.cyan(`GET ${url} ${n}`)); - const response = await fetch(url); - const { ok, status } = response; - - if (ok && status === 200) { - console.log(c.green(`200 OK, continue`)); - return; + while (n < attempts) { + n++; + console.log(c.cyan(`GET ${url} attempt ${n}/${attempts}`)); + try { + const response = await fetch(url); + const { ok, status } = response; + + if (ok && status === 200) { + console.log(c.green(`200 OK, continue`)); + return; + } + } catch (error) { + console.error(c.red(`Error during fetch: ${error.message}`)); } await new Promise(resolve => setTimeout(resolve, 1000)); diff --git a/automation/run-e2e/mendix-versions.json b/automation/run-e2e/mendix-versions.json index 23b387d1f1..276179a36d 100644 --- a/automation/run-e2e/mendix-versions.json +++ b/automation/run-e2e/mendix-versions.json @@ -1,5 +1,5 @@ { - "latest": "10.21.1.64969", + "latest": "10.24.0.73019", "9": "9.24.0.2965", "8": "8.18.24.2858" } diff --git a/automation/run-e2e/package.json b/automation/run-e2e/package.json index 3de190c729..2d6395e43f 100644 --- a/automation/run-e2e/package.json +++ b/automation/run-e2e/package.json @@ -20,17 +20,17 @@ "dependencies": { "ansi-colors": "^4.1.3", "cross-zip": "^4.0.1", - "enquirer": "^2.3.6", + "enquirer": "^2.4.1", "find-free-port": "^2.0.0", "ip": "^1.1.9", "mocha": "^10.4.0", - "node-fetch": "^2.6.9", + "node-fetch": "^2.7.0", "shelljs": "^0.8.5", "yargs-parser": "^21.1.1" }, "devDependencies": { "@axe-core/playwright": "^4.10.1", - "@eslint/js": "^9.24.0", + "@eslint/js": "^9.32.0", "@mendix/prettier-config-web-widgets": "workspace:*", "@playwright/test": "^1.51.1", "@types/node": "*", diff --git a/automation/scripts/package.json b/automation/scripts/package.json index 12bae57b4d..b1b7616dfc 100644 --- a/automation/scripts/package.json +++ b/automation/scripts/package.json @@ -14,11 +14,11 @@ "lint": "echo 'Lint disabled for now, please update package scripts'", "lint:enableme": "eslint . --ext .mjs", "root-script:commitlint": "commitlint", - "root-script:format-staged": "pretty-quick --staged --config \"./.prettierrc.cjs\" --pattern \"**/{src,script,typings,test,**}/**/*.{cjs,mjs,js,jsx,ts,tsx,scss,html,xml,md,json}\"" + "root-script:format-staged": "pretty-quick --staged --config \"./.prettierrc.cjs\" --pattern \"**/{src,script,typings,test,**}/**/*.{cjs,mjs,js,jsx,ts,tsx,scss,xml,md,json}\"" }, "dependencies": { - "@commitlint/cli": "^19.8.0", - "@commitlint/config-conventional": "^19.8.0", + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1", "@mendix/prettier-config-web-widgets": "workspace:*", "pretty-quick": "^4.1.1" }, diff --git a/automation/snapshot-generator/package.json b/automation/snapshot-generator/package.json index c8f91c5a12..cae4bc24e5 100644 --- a/automation/snapshot-generator/package.json +++ b/automation/snapshot-generator/package.json @@ -16,7 +16,7 @@ "chance": "^1.1.12" }, "devDependencies": { - "@eslint/js": "^9.24.0", + "@eslint/js": "^9.32.0", "@mendix/prettier-config-web-widgets": "workspace:*", "globals": "^16.0.0" } diff --git a/automation/utils/bin/rui-agent-rules.ts b/automation/utils/bin/rui-agent-rules.ts new file mode 100755 index 0000000000..db560e9f35 --- /dev/null +++ b/automation/utils/bin/rui-agent-rules.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env ts-node-script + +import { mkdir, readdir, lstat, symlink, unlink } from "fs/promises"; +import path from "node:path"; + +async function ensureSymlink(targetRel: string, linkPath: string): Promise { + try { + const stat = await lstat(linkPath).catch(() => null); + if (stat) { + await unlink(linkPath); + } + await symlink(targetRel, linkPath); + } catch (err) { + console.error(`Failed to create symlink for ${linkPath}:`, err); + } +} + +async function main(): Promise { + const repoRoot = path.resolve(process.cwd(), "../.."); + const SRC_DIR = path.join(repoRoot, "docs", "requirements"); + const CURSOR_DIR = path.join(repoRoot, ".cursor", "rules"); + const WINDSURF_DIR = path.join(repoRoot, ".windsurf", "rules"); + + // Ensure target directories exist + await Promise.all([mkdir(CURSOR_DIR, { recursive: true }), mkdir(WINDSURF_DIR, { recursive: true })]); + + const files = (await readdir(SRC_DIR)).filter(f => f.endsWith(".md")); + + await Promise.all( + files.map(async file => { + const base = path.basename(file, ".md"); + const srcAbsolute = path.join(SRC_DIR, file); + + const cursorLink = path.join(CURSOR_DIR, `${base}.mdc`); + const windsurfLink = path.join(WINDSURF_DIR, `${base}.md`); + + const relFromCursor = path.relative(path.dirname(cursorLink), srcAbsolute); + const relFromWindsurf = path.relative(path.dirname(windsurfLink), srcAbsolute); + + await Promise.all([ensureSymlink(relFromCursor, cursorLink), ensureSymlink(relFromWindsurf, windsurfLink)]); + }) + ); + + console.log("Agent rules links updated."); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-generate-package-xml.ts b/automation/utils/bin/rui-generate-package-xml.ts new file mode 100755 index 0000000000..0f8e9a1430 --- /dev/null +++ b/automation/utils/bin/rui-generate-package-xml.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env ts-node + +import { writeClientPackageXml, ClientPackageXML } from "../src/package-xml-v2"; +import { getWidgetInfo } from "../src/package-info"; +import { readPropertiesFile } from "../src/package-xml-v2/properties-xml"; +import path from "node:path"; +import { existsSync } from "node:fs"; + +async function generatePackageXml(): Promise { + const widgetDir = process.cwd(); + + // Read package.json info + const packageInfo = await getWidgetInfo(widgetDir); + + const srcDir = path.join(widgetDir, "src"); + + // Create src directory if it doesn't exist + if (!existsSync(srcDir)) { + throw new Error(`Src folder not found: ${srcDir}`); + } + + // Get properties file name from package.json (mxpackage.name + ".xml") + const propertiesFileName = packageInfo.mxpackage.name + ".xml"; + const propertiesFilePath = path.join(srcDir, propertiesFileName); + + // Properties file must exist + if (!existsSync(propertiesFilePath)) { + throw new Error(`Properties file not found: ${propertiesFilePath}`); + } + + // Read properties file and extract widget ID + const propertiesXml = await readPropertiesFile(propertiesFilePath); + const widgetId = propertiesXml.widget["@_id"]; + + // Generate ClientPackageXML structure + const clientPackageXml: ClientPackageXML = { + name: packageInfo.mxpackage.name, + version: packageInfo.version, + widgetFiles: [packageInfo.mxpackage.name + ".xml"], + files: [widgetId.split(".").slice(0, -1).join("/") + "/"] + }; + + // Write the generated package.xml + const packageXmlPath = path.join(srcDir, "package.xml"); + await writeClientPackageXml(packageXmlPath, clientPackageXml); +} + +async function main() { + try { + await generatePackageXml(); + } catch (error) { + console.error("Error generating package.xml:", error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} diff --git a/automation/utils/bin/rui-prepare-release.ts b/automation/utils/bin/rui-prepare-release.ts new file mode 100755 index 0000000000..253fe6a1a2 --- /dev/null +++ b/automation/utils/bin/rui-prepare-release.ts @@ -0,0 +1,460 @@ +import { Jira } from "../src/jira"; +import { PackageListing, selectPackage } from "../src/monorepo"; +import chalk from "chalk"; +import { prompt } from "enquirer"; +import { getNextVersion, writeVersion } from "../src/bump-version"; +import { exec } from "../src/shell"; +import { gh } from "../src/github"; + +async function main(): Promise { + try { + console.log(chalk.bold.cyan("\n🚀 RELEASE PREPARATION WIZARD 🚀\n")); + + console.log(chalk.bold("📋 STEP 1: Initialize Jira and GitHub")); + + // Check GitHub authentication + try { + await gh.ensureAuth(); + console.log(chalk.green("✅ GitHub authentication verified")); + } catch (error) { + console.log(chalk.red(`❌ GitHub authentication failed: ${(error as Error).message}`)); + console.log(chalk.yellow("\n💡 First, make sure GitHub CLI is installed:")); + console.log(chalk.cyan(" Download from: https://cli.github.com/")); + console.log(chalk.cyan(" Or install via brew: brew install gh")); + console.log(chalk.yellow("\n💡 Then authenticate with GitHub using one of these options:")); + console.log(chalk.yellow(" 1. Set GITHUB_TOKEN environment variable:")); + console.log(chalk.cyan(" export GITHUB_TOKEN=your_token_here")); + console.log(chalk.yellow(" 2. Set GH_PAT environment variable:")); + console.log(chalk.cyan(" export GH_PAT=your_token_here")); + console.log(chalk.yellow(" 3. Use GitHub CLI to authenticate:")); + console.log(chalk.cyan(" gh auth login")); + console.log(chalk.yellow("\n Get a token at: https://github.com/settings/tokens")); + process.exit(1); + } + + // Step 1: Initialize Jira client + let jira: Jira; + try { + jira = await initializeJiraClient(); + } catch (error) { + console.log(chalk.red(`❌ ${(error as Error).message}`)); + process.exit(1); + } + + // Step 2: Select package and determine version + console.log(chalk.bold("\n📋 STEP 2: Package Selection")); + const { pkg, baseName, nextVersion, jiraVersionName, isVersionBumped } = await selectPackageAndVersion(); + + // Step 3: Check if Jira version exists + console.log(chalk.bold("\n📋 STEP 3: Jira Version Setup")); + const jiraVersion = await checkAndCreateJiraVersion(jira, jiraVersionName); + + // Step 4: Create release branch + console.log(chalk.bold("\n📋 STEP 4: Git Operations")); + const tmpBranchName = await createReleaseBranch(baseName, nextVersion); + + // Track whether we need to commit changes + let hasCommits = false; + + // Step 4.1: Write versions to the files (if user chose to bump version) + if (isVersionBumped) { + await writeVersion(pkg, nextVersion); + console.log(chalk.green(`✅ Updated ${baseName} to ${nextVersion}`)); + + await exec(`git reset`, { stdio: "pipe" }); // Unstage all files + await exec(`git add ${pkg.path}`, { stdio: "pipe" }); // Stage only the package + + // Step 4.2: Commit changes + const { confirmCommit } = await prompt<{ confirmCommit: boolean }>({ + type: "confirm", + name: "confirmCommit", + message: "❓ Commit version changes? You can stage other files now, if needed", + initial: true + }); + + if (!confirmCommit) { + console.log(chalk.yellow("⚠️ Commit canceled. Changes remain uncommitted")); + process.exit(0); + } + await exec(`git commit -m "chore(${baseName}): bump version to ${nextVersion}"`, { stdio: "pipe" }); + console.log(chalk.green("✅ Changes committed")); + hasCommits = true; + } else { + console.log(chalk.yellow("⚠️ Version bump skipped. No changes to commit.")); + } + + // Step 4.3: Push to GitHub + const { confirmPush } = await prompt<{ confirmPush: boolean }>({ + type: "confirm", + name: "confirmPush", + message: `❓ Push branch ${chalk.blue(tmpBranchName)} to GitHub${!hasCommits ? " (without commits)" : ""}?`, + initial: true + }); + + if (!confirmPush) { + console.log(chalk.yellow("⚠️ Push canceled. Branch remains local")); + console.log(chalk.yellow(` To push manually: git push origin ${tmpBranchName}`)); + process.exit(0); + } + + await exec(`git push -u origin ${tmpBranchName}`, { stdio: "pipe" }); + console.log(chalk.green("✅ Branch pushed to GitHub")); + + console.log(chalk.bold("\n📋 STEP 5: GitHub Release Workflow")); + await triggerGitHubReleaseWorkflow(pkg.name, tmpBranchName); + + console.log(chalk.bold("\n📋 STEP 6: Jira Issue Management")); + await manageIssuesForVersion(jira, jiraVersion.id, jiraVersionName); + + console.log(chalk.cyan("\n🎉 Release preparation completed! 🎉")); + console.log(chalk.cyan(` Package: ${baseName} v${nextVersion}`)); + console.log(chalk.cyan(` Branch: ${tmpBranchName}`)); + console.log(chalk.cyan(` Jira Version: ${jiraVersionName}`)); + if (!isVersionBumped) { + console.log(chalk.cyan(` Note: Version was not bumped as requested`)); + } + } catch (error) { + console.error(chalk.red("\n❌ ERROR:"), error); + process.exit(1); + } +} + +function showManualTriggerInstructions(packageName: string, branchName: string): void { + console.log(chalk.yellow("\n⚠️ Trigger GitHub workflow manually:")); + console.log( + chalk.cyan(" 1. Go to") + " https://github.com/mendix/web-widgets/actions/workflows/CreateGitHubRelease.yml" + ); + console.log(chalk.cyan(" 2. Click") + " 'Run workflow' button"); + console.log(chalk.cyan(" 3. Enter branch:") + ` ${chalk.white(branchName)}`); + console.log(chalk.cyan(" 4. Enter package:") + ` ${chalk.white(packageName)}`); + console.log(chalk.cyan(" 5. Click") + " 'Run workflow'"); +} + +async function manageIssuesForVersion(jira: Jira, versionId: string, versionName: string): Promise { + const { manageIssues } = await prompt<{ manageIssues: boolean }>({ + type: "confirm", + name: "manageIssues", + message: `❓ Manage issues for version ${chalk.blue(versionName)}?`, + initial: true + }); + + if (!manageIssues) { + return; + } + + console.log(chalk.bold(`\n📋 Managing issues for version ${chalk.blue(versionName)}`)); + + let managing = true; + while (managing) { + // Get current issues + const issues = await jira.getIssuesWithDetailsForVersion(versionId); + + console.log(chalk.bold(`\n🔖 Issues for ${chalk.blue(versionName)} (${issues.length}):`)); + if (issues.length === 0) { + console.log(chalk.yellow(" No issues assigned to this version yet")); + } else { + issues.forEach((issue, index) => { + console.log(` ${index + 1}. ${chalk.cyan(issue.key)}: ${issue.fields.summary}`); + }); + } + + const { action } = await prompt<{ action: string }>({ + type: "select", + name: "action", + message: "What would you like to do?", + choices: [ + { name: "add", message: "Add an issue" }, + { name: "remove", message: "Remove an issue" }, + { name: "refresh", message: "Refresh issue list" }, + { name: "exit", message: "Exit" } + ] + }); + + switch (action) { + case "add": { + const { issueKey } = await prompt<{ issueKey: string }>({ + type: "input", + name: "issueKey", + message: "Enter issue key (e.g., WEB-1234)" + }); + + const issue = await jira.searchIssueByKey(issueKey); + if (!issue) { + console.log(chalk.red(`❌ Issue ${chalk.cyan(issueKey)} not found`)); + break; + } + + console.log(`Found: ${chalk.cyan(issue.key)}: ${issue.fields.summary}`); + + const { confirm } = await prompt<{ confirm: boolean }>({ + type: "confirm", + name: "confirm", + message: `❓ Assign ${chalk.cyan(issue.key)} to version ${chalk.blue(versionName)}?`, + initial: true + }); + + if (confirm) { + await jira.assignVersionToIssue(versionId, issue.key); + console.log( + chalk.green(`✅ Issue ${chalk.cyan(issue.key)} assigned to ${chalk.blue(versionName)}`) + ); + } + break; + } + + case "remove": { + if (issues.length === 0) { + console.log(chalk.yellow("⚠️ No issues to remove")); + break; + } + + const { selectedIssue } = await prompt<{ selectedIssue: string }>({ + type: "select", + name: "selectedIssue", + message: "Select issue to remove", + choices: issues.map(issue => ({ + name: issue.key, + message: `${issue.key}: ${issue.fields.summary}` + })) + }); + + const { confirmRemove } = await prompt<{ confirmRemove: boolean }>({ + type: "confirm", + name: "confirmRemove", + message: `❓ Remove ${chalk.cyan(selectedIssue)} from ${chalk.blue(versionName)}?`, + initial: true + }); + + if (confirmRemove) { + await jira.removeFixVersionFromIssue(versionId, selectedIssue); + console.log(chalk.green(`✅ Removed ${chalk.cyan(selectedIssue)} from ${chalk.blue(versionName)}`)); + } + break; + } + + case "refresh": + console.log(chalk.blue("🔄 Refreshing issue list...")); + break; + + case "exit": + managing = false; + break; + } + } +} + +async function triggerGitHubReleaseWorkflow(packageName: string, branchName: string): Promise { + const { triggerWorkflow } = await prompt<{ triggerWorkflow: boolean }>({ + type: "confirm", + name: "triggerWorkflow", + message: "❓ Trigger GitHub release workflow now?", + initial: true + }); + + if (triggerWorkflow) { + console.log(chalk.blue("🔄 Triggering GitHub release workflow...")); + try { + await gh.triggerCreateReleaseWorkflow(packageName, branchName); + console.log(chalk.green("✅ GitHub Release Workflow triggered")); + } catch (error) { + console.error(chalk.red(`❌ Failed to trigger workflow: ${(error as Error).message}`)); + showManualTriggerInstructions(packageName, branchName); + } + } else { + showManualTriggerInstructions(packageName, branchName); + } +} + +async function createReleaseBranch(packageName: string, version: string): Promise { + const tmpBranchName = `tmp/${packageName}-v${version}`; + + let branchToUse = tmpBranchName; + let branchesAreReady = false; + + while (!branchesAreReady) { + // Check if branch exists locally + let localBranchExists = false; + try { + const { stdout: localBranchCheck } = await exec(`git branch --list ${tmpBranchName}`, { stdio: "pipe" }); + localBranchExists = localBranchCheck.trim().includes(tmpBranchName); + } catch (error) { + console.warn(chalk.yellow(`⚠️ Could not check local branch: ${(error as Error).message}`)); + } + + // Check if branch exists on remote + let remoteBranchExists = false; + try { + const { stdout: remoteBranchCheck } = await exec(`git ls-remote --heads origin ${tmpBranchName}`, { + stdio: "pipe" + }); + remoteBranchExists = remoteBranchCheck.trim().includes(tmpBranchName); + } catch (error) { + console.warn(chalk.yellow(`⚠️ Could not check remote branch: ${(error as Error).message}`)); + } + + if (!localBranchExists && !remoteBranchExists) { + branchesAreReady = true; + continue; + } + + console.log( + chalk.yellow( + `⚠️ Branch ${chalk.blue(tmpBranchName)} exists ${localBranchExists ? "locally" : ""}${localBranchExists && remoteBranchExists ? " and " : ""}${remoteBranchExists ? "on remote" : ""}` + ) + ); + + // Show manual deletion instructions + console.log(chalk.cyan("\n🗑️ Branch deletion instructions:")); + if (localBranchExists) { + console.log(chalk.cyan(" To delete local branch:")); + console.log(chalk.white(` 1. Switch to another branch: git checkout main`)); + console.log(chalk.white(` 2. Delete branch: git branch -D ${tmpBranchName}`)); + } + if (remoteBranchExists) { + console.log(chalk.cyan(" To delete remote branch:")); + console.log(chalk.white(` Run: git push origin --delete ${tmpBranchName}`)); + } + + const { branchAction } = await prompt<{ branchAction: string }>({ + type: "select", + name: "branchAction", + message: "What would you like to do?", + choices: [ + { name: "checkAgain", message: "I've deleted the branches, check again" }, + { name: "random", message: "Create branch with random suffix" }, + { name: "cancel", message: "Cancel operation" } + ] + }); + + switch (branchAction) { + case "checkAgain": + console.log(chalk.blue("🔄 Rechecking branch status...")); + break; + + case "random": + const randomSuffix = Math.random().toString(36).substring(2, 8); + branchToUse = `${tmpBranchName}-${randomSuffix}`; + console.log(chalk.blue(`🔀 Using branch: ${branchToUse}`)); + branchesAreReady = true; + break; + + case "cancel": + console.log(chalk.red("❌ Process canceled")); + process.exit(1); + } + } + + // Now create the branch + console.log(`🔀 Creating branch: ${chalk.blue(branchToUse)}`); + await exec(`git checkout -b ${branchToUse}`, { stdio: "pipe" }); + console.log(chalk.green("✅ Branch created")); + + return branchToUse; +} + +async function initializeJiraClient(): Promise { + const projectKey = process.env.JIRA_PROJECT_KEY; + const baseUrl = process.env.JIRA_BASE_URL; + const apiToken = process.env.JIRA_API_TOKEN; + + if (!projectKey || !baseUrl || !apiToken) { + console.error(chalk.red("❌ Missing Jira environment variables")); + console.log(chalk.dim(" Required variables:")); + console.log(chalk.dim(" export JIRA_PROJECT_KEY=WEB")); + console.log(chalk.dim(" export JIRA_BASE_URL=https://your-company.atlassian.net")); + console.log(chalk.dim(" export JIRA_API_TOKEN=username@your-company.com:ATATT3xFfGF0...")); + console.log(chalk.dim(" Get your API token at: https://id.atlassian.com/manage-profile/security/api-tokens")); + throw new Error("Missing Jira environment variables"); + } + + // Initialize Jira client + const jira = new Jira(projectKey, baseUrl, apiToken); + + // Initialize Jira project data with retry mechanism + let initialized = false; + while (!initialized) { + try { + console.log("🔄 Initializing Jira project data..."); + await jira.initializeProjectData(); + console.log(chalk.green("✅ Jira project data initialized")); + initialized = true; + } catch (error) { + console.error(chalk.red(`❌ Jira init failed: ${(error as Error).message}`)); + + const { retry } = await prompt<{ retry: boolean }>({ + type: "confirm", + name: "retry", + message: "❓ Retry Jira initialization?", + initial: true + }); + + if (!retry) { + throw new Error("Cannot proceed without Jira initialization"); + } + } + } + + return jira; +} + +async function selectPackageAndVersion(): Promise<{ + pkg: PackageListing; + baseName: string; + nextVersion: string; + jiraVersionName: string; + isVersionBumped: boolean; +}> { + const pkg = await selectPackage(); + const baseName = pkg.name.split("/").pop()!; + + console.log(`📦 Selected: ${chalk.blue(baseName)} (current: ${chalk.green(pkg.version)})`); + + // Ask user if they want to bump the version before showing version selection dialog + const { confirmBumpVersion } = await prompt<{ confirmBumpVersion: boolean }>({ + type: "confirm", + name: "confirmBumpVersion", + message: `❓ Do you want to bump ${baseName} from version ${chalk.green(pkg.version)}?`, + initial: true + }); + + // Only call getNextVersion if user wants to bump version + let nextVersion = pkg.version; + if (confirmBumpVersion) { + nextVersion = await getNextVersion(pkg.version); + console.log(`🔼 Next version: ${chalk.green(nextVersion)}`); + } else { + console.log(chalk.yellow(`⚠️ Version bump skipped. Keeping version ${chalk.green(pkg.version)}`)); + } + + const jiraVersionName = `${baseName}-v${nextVersion}`; + + return { pkg, baseName, nextVersion, jiraVersionName, isVersionBumped: confirmBumpVersion }; +} + +async function checkAndCreateJiraVersion(jira: Jira, jiraVersionName: string): Promise { + let jiraVersion = jira.findVersion(jiraVersionName); + if (jiraVersion) { + console.log(chalk.yellow(`⚠️ Jira version ${chalk.blue(jiraVersionName)} already exists`)); + } else { + // Ask user for confirmation to create new version + const { createVersion } = await prompt<{ createVersion: boolean }>({ + type: "confirm", + name: "createVersion", + message: `❓ Create Jira version ${chalk.blue(jiraVersionName)}?`, + initial: true + }); + + if (!createVersion) { + console.log(chalk.red("❌ Process canceled")); + process.exit(1); + } + + // Create Jira version + jiraVersion = await jira.createVersion(jiraVersionName); + console.log(chalk.green(`✅ Created Jira version ${chalk.blue(jiraVersionName)}`)); + } + + return jiraVersion; +} + +main(); diff --git a/automation/utils/bin/rui-publish-marketplace.ts b/automation/utils/bin/rui-publish-marketplace.ts index ad117ad6c7..ce1a514357 100755 --- a/automation/utils/bin/rui-publish-marketplace.ts +++ b/automation/utils/bin/rui-publish-marketplace.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import { getPublishedInfo, gh } from "../src"; -import { fgGreen } from "../src/ansi-colors"; import { createDraft, publishDraft } from "../src/api/contributor"; +import chalk from "chalk"; async function main(): Promise { console.log(`Getting package information...`); @@ -13,11 +13,11 @@ async function main(): Promise { assert.ok(tag, "env.TAG is empty"); if (marketplace.appNumber === -1) { - console.log(`Skipping release process for tag ${fgGreen(tag)}. appNumber is set to -1 in package.json.`); + console.log(`Skipping release process for tag ${chalk.green(tag)}. appNumber is set to -1 in package.json.`); process.exit(2); } - console.log(`Starting release process for tag ${fgGreen(tag)}`); + console.log(`Starting release process for tag ${chalk.green(tag)}`); const artifactUrl = await gh.getMPKReleaseArtifactUrl(tag); diff --git a/automation/utils/bin/rui-verify-package-format.ts b/automation/utils/bin/rui-verify-package-format.ts index 3653aa2597..6be66d9548 100755 --- a/automation/utils/bin/rui-verify-package-format.ts +++ b/automation/utils/bin/rui-verify-package-format.ts @@ -2,15 +2,16 @@ import { ZodError } from "zod"; import { + getModuleChangelog, getPackageFileContent, - PackageSchema, - ModulePackageSchema, + getWidgetChangelog, JSActionsPackageSchema, + ModulePackageSchema, + PackageSchema, PublishedPackageSchema } from "../src"; import { verify as verifyWidget } from "../src/verify-widget-manifest"; -import { fgCyan, fgGreen, fgYellow } from "../src/ansi-colors"; -import { getModuleChangelog, getWidgetChangelog } from "../src"; +import chalk from "chalk"; async function main(): Promise { const path = process.cwd(); @@ -63,13 +64,13 @@ async function main(): Promise { // Changelog check coming soon... - console.log(fgGreen("Verification success")); + console.log(chalk.green("Verification success")); } catch (error) { if (error instanceof ZodError) { for (const issue of error.issues) { - const keys = issue.path.map(x => fgYellow(`${x}`)); + const keys = issue.path.map(x => chalk.yellow(`${x}`)); const code = `[${issue.code}]`; - console.error(`package.${keys.join(".")} - ${code} ${fgCyan(issue.message)}`); + console.error(`package.${keys.join(".")} - ${code} ${chalk.cyan(issue.message)}`); } // Just for new line console.log(""); diff --git a/automation/utils/docker/mxbuild.Dockerfile b/automation/utils/docker/mxbuild.Dockerfile index 7885937ebb..ba69d1f0a7 100644 --- a/automation/utils/docker/mxbuild.Dockerfile +++ b/automation/utils/docker/mxbuild.Dockerfile @@ -1,28 +1,38 @@ -FROM mono:6.12 +FROM --platform=$BUILDPLATFORM eclipse-temurin:17-jdk-jammy + ARG MENDIX_VERSION +ARG BUILDPLATFORM +SHELL ["/bin/bash", "-c"] RUN \ - echo "Installing Java..." && \ - apt-get -qq update && \ - apt-get -qq install -y wget && \ - wget -q https://download.java.net/java/GA/jdk11/9/GPL/openjdk-11.0.2_linux-x64_bin.tar.gz -O /tmp/openjdk.tar.gz && \ - mkdir /usr/lib/jvm && \ - tar xfz /tmp/openjdk.tar.gz --directory /usr/lib/jvm && \ - rm /tmp/openjdk.tar.gz && \ -\ - echo "Downloading mxbuild ${MENDIX_VERSION}..." && \ - wget -q https://cdn.mendix.com/runtime/mxbuild-${MENDIX_VERSION}.tar.gz -O /tmp/mxbuild.tar.gz && \ - mkdir /tmp/mxbuild && \ - tar xfz /tmp/mxbuild.tar.gz --directory /tmp/mxbuild && \ - rm /tmp/mxbuild.tar.gz && \ +echo "Downloading mxbuild ${MENDIX_VERSION} and docker building for ${BUILDPLATFORM}..." \ + && case "${BUILDPLATFORM}" in \ + linux/arm64) \ + BINARY_URL="https://cdn.mendix.com/runtime/arm64-mxbuild-${MENDIX_VERSION}.tar.gz"; \ + ;; \ + linux/amd64) \ + BINARY_URL="https://cdn.mendix.com/runtime/mxbuild-${MENDIX_VERSION}.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported architecture: ${BUILDPLATFORM}" >&2; \ + exit 1; \ + ;; \ + esac \ + && echo "Downloading from: ${BINARY_URL}" \ + && wget -q "${BINARY_URL}" -O /tmp/mxbuild.tar.gz \ + && mkdir /tmp/mxbuild \ + && tar xfz /tmp/mxbuild.tar.gz --directory /tmp/mxbuild \ + && rm /tmp/mxbuild.tar.gz && \ \ - apt-get -qq remove -y wget && \ + apt-get update -qqy && \ + apt-get install -qqy libicu70 libgdiplus && \ + apt-get -qqy remove --auto-remove wget && \ apt-get clean && \ \ echo "#!/bin/bash -x" >/bin/mxbuild && \ - echo "mono /tmp/mxbuild/modeler/mxbuild.exe --java-home=/usr/lib/jvm/jdk-11.0.2 --java-exe-path=/usr/lib/jvm/jdk-11.0.2/bin/java \$@" >>/bin/mxbuild && \ + echo "/tmp/mxbuild/modeler/mxbuild --java-home=/opt/java/openjdk --java-exe-path=/opt/java/openjdk/bin/java \$@" >>/bin/mxbuild && \ chmod +x /bin/mxbuild && \ \ echo "#!/bin/bash -x" >/bin/mx && \ - echo "mono /tmp/mxbuild/modeler/mx.exe \$@" >>/bin/mx && \ + echo "/tmp/mxbuild/modeler/mx \$@" >>/bin/mx && \ chmod +x /bin/mx diff --git a/automation/utils/docker/mxbuildMx10.Dockerfile b/automation/utils/docker/mxbuildMx10.Dockerfile deleted file mode 100644 index eb0cbe77c9..0000000000 --- a/automation/utils/docker/mxbuildMx10.Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -FROM --platform=$BUILDPLATFORM eclipse-temurin:17-jdk-jammy - -ARG MENDIX_VERSION -ARG BUILDPLATFORM - -SHELL ["/bin/bash", "-c"] -RUN \ -echo "Downloading mxbuild ${MENDIX_VERSION} and docker building for ${BUILDPLATFORM}..." \ - && case "${BUILDPLATFORM}" in \ - linux/arm64) \ - BINARY_URL="https://cdn.mendix.com/runtime/arm64-mxbuild-${MENDIX_VERSION}.tar.gz"; \ - ;; \ - linux/amd64) \ - BINARY_URL="https://cdn.mendix.com/runtime/mxbuild-${MENDIX_VERSION}.tar.gz"; \ - ;; \ - *) \ - echo "Unsupported architecture: ${BUILDPLATFORM}" >&2; \ - exit 1; \ - ;; \ - esac \ - && echo "Downloading from: ${BINARY_URL}" \ - && wget -q "${BINARY_URL}" -O /tmp/mxbuild.tar.gz \ - && mkdir /tmp/mxbuild \ - && tar xfz /tmp/mxbuild.tar.gz --directory /tmp/mxbuild \ - && rm /tmp/mxbuild.tar.gz && \ -\ - apt-get update -qqy && \ - apt-get install -qqy libicu70 && \ - apt-get -qqy remove --auto-remove wget && \ - apt-get clean && \ -\ - echo "#!/bin/bash -x" >/bin/mxbuild && \ - echo "/tmp/mxbuild/modeler/mxbuild --java-home=/opt/java/openjdk --java-exe-path=/opt/java/openjdk/bin/java \$@" >>/bin/mxbuild && \ - chmod +x /bin/mxbuild && \ -\ - echo "#!/bin/bash -x" >/bin/mx && \ - echo "/tmp/mxbuild/modeler/mx \$@" >>/bin/mx && \ - chmod +x /bin/mx diff --git a/automation/utils/package.json b/automation/utils/package.json index 85c2af142d..483509d4d9 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -5,8 +5,11 @@ "copyright": "© Mendix Technology BV 2025. All rights reserved.", "private": true, "bin": { + "rui-agent-rules": "bin/rui-agent-rules.ts", "rui-create-gh-release": "bin/rui-create-gh-release.ts", "rui-create-translation": "bin/rui-create-translation.ts", + "rui-generate-package-xml": "bin/rui-generate-package-xml.ts", + "rui-prepare-release": "bin/rui-prepare-release.ts", "rui-publish-marketplace": "bin/rui-publish-marketplace.ts", "rui-update-changelog-module": "bin/rui-update-changelog-module.ts", "rui-update-changelog-widget": "bin/rui-update-changelog-widget.ts", @@ -21,12 +24,14 @@ "tsconfig.json" ], "scripts": { + "agent-rules": "ts-node bin/rui-agent-rules.ts", "changelog": "ts-node bin/rui-changelog-helper.ts", "compile:parser:module": "peggy -o ./src/changelog-parser/parser/widget/widget.js ./src/changelog-parser/parser/widget/widget.pegjs", "compile:parser:widget": "peggy -o ./src/changelog-parser/parser/module/module.js ./src/changelog-parser/parser/module/module.pegjs", "format": "prettier --write .", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", "prepare": "pnpm run compile:parser:widget && pnpm run compile:parser:module && tsc", + "prepare-release": "ts-node bin/rui-prepare-release.ts", "start": "tsc --watch", "version": "ts-node bin/rui-bump-version.ts" }, @@ -34,17 +39,18 @@ "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/prettier-config-web-widgets": "workspace:*", "@types/cross-zip": "^4.0.2", - "@types/node-fetch": "2.6.2", - "chalk": "^4.1.2", + "@types/node-fetch": "2.6.12", + "chalk": "^5.4.1", "cross-zip": "^4.0.1", - "enquirer": "^2.3.6", + "enquirer": "^2.4.1", "execa": "^5.1.1", "fast-xml-parser": "^4.1.3", - "node-fetch": "^2.6.9", + "glob": "^11.0.3", + "node-fetch": "^2.7.0", "ora": "^5.4.1", "peggy": "^1.2.0", "shelljs": "^0.8.5", "ts-node": "^10.9.1", - "zod": "^3.20.6" + "zod": "^3.25.67" } } diff --git a/automation/utils/src/ansi-colors.ts b/automation/utils/src/ansi-colors.ts deleted file mode 100644 index a48a1dbf06..0000000000 --- a/automation/utils/src/ansi-colors.ts +++ /dev/null @@ -1,9 +0,0 @@ -const bindColor = (ansiColor: string) => (str: string) => `${ansiColor}${str}\x1b[0m`; - -export const fgRed = bindColor("\x1b[31m"); -export const fgGreen = bindColor("\x1b[32m"); -export const fgYellow = bindColor("\x1b[33m"); -export const fgBlue = bindColor("\x1b[34m"); -export const fgMagenta = bindColor("\x1b[35m"); -export const fgCyan = bindColor("\x1b[36m"); -export const fgWhite = bindColor("\x1b[37m"); diff --git a/automation/utils/src/api/contributor.ts b/automation/utils/src/api/contributor.ts index b67e1c3ca1..6eb230ef3c 100644 --- a/automation/utils/src/api/contributor.ts +++ b/automation/utils/src/api/contributor.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; -import { fetch, BodyInit } from "../fetch"; +import { BodyInit, fetch } from "../fetch"; import { z } from "zod"; import { Version } from "../version"; -import { fgGreen } from "../ansi-colors"; +import chalk from "chalk"; export interface CreateDraftSuccessResponse { App: App; @@ -115,7 +115,7 @@ export async function createDraft(params: CreateDraftParams): Promise { @@ -65,7 +65,7 @@ export async function getWidgetBuildConfig({ console.info(`Creating build config for ${packageName}...`); if (MX_PROJECT_PATH) { - console.info(fgGreen(`targetProject: using project path from MX_PROJECT_PATH.`)); + console.info(chalk.green(`targetProject: using project path from MX_PROJECT_PATH.`)); } const paths = { @@ -118,7 +118,7 @@ export async function getModuleBuildConfig({ console.info(`Creating build config for ${packageName}...`); if (MX_PROJECT_PATH) { - console.info(fgGreen(`targetProject: using project path from MX_PROJECT_PATH.`)); + console.info(chalk.green(`targetProject: using project path from MX_PROJECT_PATH.`)); } const paths = { diff --git a/automation/utils/src/changelog.ts b/automation/utils/src/changelog.ts index e3dfe7bd11..93abe42eab 100644 --- a/automation/utils/src/changelog.ts +++ b/automation/utils/src/changelog.ts @@ -1,6 +1,8 @@ import { gh } from "./github"; import { PublishedInfo } from "./package-info"; -import { exec, pushd, popd } from "./shell"; +import { exec, popd, pushd } from "./shell"; +import { findOssReadme } from "./oss-readme"; +import { join } from "path"; export async function updateChangelogsAndCreatePR( info: PublishedInfo, @@ -10,6 +12,30 @@ export async function updateChangelogsAndCreatePR( const releaseBranchName = `${releaseTag}-update-changelog`; const appName = info.marketplace.appName; + // Check if branch already exists on remote + try { + const { stdout: remoteOutput } = await exec(`git ls-remote --heads ${remoteName} ${releaseBranchName}`, { + stdio: "pipe" + }); + if (remoteOutput.trim()) { + // Branch exists on remote, get its commit hash and rename it + const remoteCommitHash = remoteOutput.split("\t")[0].substring(0, 7); // Get short hash from remote output + const renamedBranchName = `${releaseBranchName}-${remoteCommitHash}`; + + console.log( + `Branch '${releaseBranchName}' already exists on remote. Renaming it to '${renamedBranchName}'...` + ); + + // Create new branch on remote with the renamed name pointing to the same commit + await exec(`git push ${remoteName} ${remoteName}/${releaseBranchName}:refs/heads/${renamedBranchName}`); + // Delete the old branch on remote + await exec(`git push ${remoteName} --delete ${releaseBranchName}`); + } + } catch (error) { + // Branch doesn't exist on remote, continue with original name + console.log(`Using branch name '${releaseBranchName}'`); + } + console.log(`Creating branch '${releaseBranchName}'...`); await exec(`git checkout -b ${releaseBranchName}`); @@ -26,6 +52,14 @@ export async function updateChangelogsAndCreatePR( const { stdout: root } = await exec(`git rev-parse --show-toplevel`, { stdio: "pipe" }); pushd(root.trim()); await exec(`git add '*/CHANGELOG.md'`); + + const path = process.cwd(); + const readmeossFile = findOssReadme(path, info.mxpackage.name, info.version.format()); + if (readmeossFile) { + console.log(`Removing OSS clearance readme file '${readmeossFile}'...`); + await exec(`git rm '${readmeossFile}'`); + } + await exec(`git commit -m "chore(${info.name}): update changelog"`); await exec(`git push ${remoteName} ${releaseBranchName}`); popd(); @@ -33,7 +67,7 @@ export async function updateChangelogsAndCreatePR( console.log(`Creating pull request for '${releaseBranchName}'`); await gh.createGithubPRFrom({ title: `${appName} v${info.version.format()}: Update changelog`, - body: "This is an automated PR that merges changelog update to master.", + body: "This is an automated PR that merges changelog update to main branch.", base: "main", head: releaseBranchName, repo: info.repository.url diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index b5754d3672..ef5f7c92d0 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -1,7 +1,7 @@ -import { exec } from "./shell"; -import { fetch } from "./fetch"; import { mkdtemp, writeFile } from "fs/promises"; import { join } from "path"; +import { fetch } from "./fetch"; +import { exec } from "./shell"; interface GitHubReleaseInfo { title: string; @@ -30,9 +30,33 @@ export class GitHub { authSet = false; tmpPrefix = "gh-"; - private async ensureAuth(): Promise { + async ensureAuth(): Promise { if (!this.authSet) { - await exec(`echo "${process.env.GH_PAT}" | gh auth login --with-token`); + if (process.env.GITHUB_TOKEN) { + // when using GITHUB_TOKEN, gh will automatically use it + } else if (process.env.GH_PAT) { + await exec(`echo "${process.env.GH_PAT}" | gh auth login --with-token`); + } else { + // No environment variables set, check if already authenticated + if (!(await this.isAuthenticated())) { + throw new Error( + "GitHub CLI is not authenticated. Please set GITHUB_TOKEN or GH_PAT environment variable, or run 'gh auth login'." + ); + } + } + + this.authSet = true; + } + } + + private async isAuthenticated(): Promise { + try { + // Try to run 'gh auth status' to check if authenticated + await exec("gh auth status", { stdio: "pipe" }); + return true; + } catch (error) { + // If the command fails, the user is not authenticated + return false; } } @@ -80,13 +104,22 @@ export class GitHub { await exec(command); } + get ghAPIHeaders(): Record { + return { + "X-GitHub-Api-Version": "2022-11-28", + Authorization: `Bearer ${process.env.GH_PAT}` + }; + } + async getReleaseIdByReleaseTag(releaseTag: string): Promise { console.log(`Searching for release from Github tag '${releaseTag}'`); try { const release = (await fetch<{ id: string }>( "GET", - `https://api.github.com/repos/mendix/web-widgets/releases/tags/${releaseTag}` + `https://api.github.com/repos/mendix/web-widgets/releases/tags/${releaseTag}`, + undefined, + { ...this.ghAPIHeaders } )) ?? []; if (!release) { @@ -115,7 +148,9 @@ export class GitHub { name: string; browser_download_url: string; }> - >("GET", `https://api.github.com/repos/mendix/web-widgets/releases/${releaseId}/assets`); + >("GET", `https://api.github.com/repos/mendix/web-widgets/releases/${releaseId}/assets`, undefined, { + ...this.ghAPIHeaders + }); } async getMPKReleaseArtifactUrl(releaseTag: string): Promise { @@ -136,6 +171,46 @@ export class GitHub { return filePath; } + + private async triggerGithubWorkflow(params: { + workflowId: string; + ref: string; + inputs: Record; + owner?: string; + repo?: string; + }): Promise { + await this.ensureAuth(); + + const { workflowId, ref, inputs, owner = "mendix", repo = "web-widgets" } = params; + + // Convert inputs object to CLI parameters + const inputParams = Object.entries(inputs) + .map(([key, value]) => `-f ${key}=${value}`) + .join(" "); + + const repoParam = `${owner}/${repo}`; + + const command = [`gh workflow run`, `"${workflowId}"`, `--ref "${ref}"`, inputParams, `-R "${repoParam}"`] + .filter(Boolean) + .join(" "); + + try { + await exec(command); + console.log(`Successfully triggered workflow '${workflowId}'`); + } catch (error) { + throw new Error(`Failed to trigger workflow '${workflowId}': ${error}`); + } + } + + async triggerCreateReleaseWorkflow(packageName: string, ref = "main"): Promise { + return this.triggerGithubWorkflow({ + workflowId: "CreateGitHubRelease.yml", + ref, + inputs: { + package: packageName + } + }); + } } export const gh = new GitHub(); diff --git a/automation/utils/src/jira.ts b/automation/utils/src/jira.ts new file mode 100644 index 0000000000..c0d3a992e4 --- /dev/null +++ b/automation/utils/src/jira.ts @@ -0,0 +1,184 @@ +import nodefetch, { RequestInit } from "node-fetch"; + +interface JiraVersion { + id: string; + name: string; + archived: boolean; + released: boolean; +} + +interface JiraProject { + id: string; + key: string; + name: string; +} + +interface JiraIssue { + key: string; + fields: { + summary: string; + }; +} + +export class Jira { + private projectKey: string; + private baseUrl: string; + private apiToken: string; + + private projectId: string | undefined; + private projectVersions: JiraVersion[] | undefined; + + constructor(projectKey: string, baseUrl: string, apiToken: string) { + if (!apiToken) { + throw new Error("API token is required."); + } + this.projectKey = projectKey; + this.baseUrl = baseUrl; + + this.apiToken = Buffer.from(apiToken).toString("base64"); // Convert to Base64 + } + + // Private helper method for making API requests + private async apiRequest( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + endpoint: string, + body?: object + ): Promise { + const url = `${this.baseUrl}/rest/api/3${endpoint}`; + const headers = { Authorization: `Basic ${this.apiToken}` }; + + const httpsOptions: RequestInit = { + method, + redirect: "follow", + headers: { + Accept: "application/json", + ...headers, + ...(body && { "Content-Type": "application/json" }) + }, + body: body ? JSON.stringify(body) : undefined + }; + + let response; + try { + response = await nodefetch(url, httpsOptions); + } catch (error) { + throw new Error(`API request failed: ${(error as Error).message}`); + } + + if (!response.ok) { + throw new Error(`API request failed (${response.status}): ${response.statusText}`); + } + + if (response.status === 204) { + // No content, return empty object + return {} as T; + } + + return response.json(); + } + + async initializeProjectData(): Promise { + const projectData = await this.apiRequest( + "GET", + `/project/${this.projectKey}` + ); + + this.projectId = projectData.id; // Save project ID + this.projectVersions = projectData.versions.reverse(); // Save list of versions + } + + private versions(): JiraVersion[] { + if (!this.projectVersions) { + throw new Error("Project versions not initialized. Call initializeProjectData() first."); + } + return this.projectVersions; + } + + getVersions(): JiraVersion[] { + return this.versions(); + } + + findVersion(versionName: string): JiraVersion | undefined { + return this.versions().find(version => version.name === versionName); + } + + async createVersion(name: string): Promise { + const version = await this.apiRequest("POST", `/version`, { + projectId: this.projectId, + name + }); + + this.projectVersions!.unshift(version); + + return version; + } + + async assignVersionToIssue(versionId: string, issueKey: string): Promise { + await this.apiRequest("PUT", `/issue/${issueKey}`, { + fields: { + fixVersions: [{ id: versionId }] + } + }); + } + + async deleteVersion(versionId: string): Promise { + await this.apiRequest("DELETE", `/version/${versionId}`); + + // Remove the version from the cached project versions + this.projectVersions = this.projectVersions?.filter(version => version.id !== versionId); + } + + async getFixVersionsForIssue(issueKey: string): Promise { + const issue = await this.apiRequest<{ fields: { fixVersions: JiraVersion[] } }>( + "GET", + `/issue/${issueKey}?fields=fixVersions` + ); + + return issue.fields.fixVersions || []; + } + + async removeFixVersionFromIssue(versionId: string, issueKey: string): Promise { + // First, get current fix versions + const currentVersions = await this.getFixVersionsForIssue(issueKey); + + // Filter out the version to remove + const updatedVersions = currentVersions + .filter(version => version.id !== versionId) + .map(version => ({ id: version.id })); + + // Update the issue with the filtered versions + await this.apiRequest("PUT", `/issue/${issueKey}`, { + fields: { + fixVersions: updatedVersions + } + }); + } + + private async getIssuesForVersion(versionId: string): Promise { + const issues = await this.apiRequest<{ issues: Array<{ key: string }> }>( + "GET", + `/search?jql=fixVersion=${versionId}` + ); + + return issues.issues.map(issue => issue.key); + } + + async getIssuesWithDetailsForVersion(versionId: string): Promise { + const response = await this.apiRequest<{ issues: JiraIssue[] }>( + "GET", + `/search?jql=fixVersion=${versionId}&fields=summary` + ); + + return response.issues; + } + + async searchIssueByKey(issueKey: string): Promise { + try { + const issue = await this.apiRequest("GET", `/issue/${issueKey}?fields=summary`); + return issue; + } catch (_e) { + // If issue not found or other error + return null; + } + } +} diff --git a/automation/utils/src/monorepo.ts b/automation/utils/src/monorepo.ts index 42dc5e9d1d..65295e1c96 100644 --- a/automation/utils/src/monorepo.ts +++ b/automation/utils/src/monorepo.ts @@ -1,6 +1,6 @@ import { prompt } from "enquirer"; import { oraPromise } from "./cli-utils"; -import { exec, find, mkdir, cp } from "./shell"; +import { cp, exec, find, mkdir } from "./shell"; type DependencyName = string; @@ -43,11 +43,34 @@ export async function copyMpkFiles(packageNames: string[], dest: string): Promis export async function selectPackage(): Promise { const pkgs = await oraPromise(listPackages(["'*'", "!web-widgets"]), "Loading packages..."); + // First, get all display names and find maximum length + const displayData = pkgs.map(pkg => { + const [category, folderName] = extractPathInfo(pkg.path); + const displayName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name; + const categoryInfo = `[${category}${displayName !== folderName ? "/" + folderName : ""}]`; + + return { + displayName, + categoryInfo, + packageName: pkg.name + }; + }); + + // Find maximum display name length for padding + const maxDisplayNameLength = Math.max(...displayData.map(item => item.displayName.length)); + const { packageName } = await prompt<{ packageName: string }>({ type: "autocomplete", name: "packageName", message: "Please select package", - choices: pkgs.map(pkg => pkg.name) + choices: displayData.map(item => { + // Pad the display name with spaces to align the category info + const paddedName = item.displayName.padEnd(maxDisplayNameLength + 2, " "); + return { + name: `${paddedName}${item.categoryInfo}`, + value: item.packageName + }; + }) }); const pkg = pkgs.find(p => p.name === packageName); @@ -58,3 +81,17 @@ export async function selectPackage(): Promise { return pkg; } + +function extractPathInfo(path: string): [string, string] { + const automationMatch = path.match(/automation\/([^/]+)/); + if (automationMatch) { + return ["automation", automationMatch[1]]; + } + + const packagesMatch = path.match(/packages\/([^/]+)\/([^/]+)/); + if (packagesMatch) { + return [packagesMatch[1], packagesMatch[2]]; + } + + throw new Error(`Invalid path format: ${path}`); +} diff --git a/automation/utils/src/mpk.ts b/automation/utils/src/mpk.ts index d4b21508f8..dd30881cd5 100644 --- a/automation/utils/src/mpk.ts +++ b/automation/utils/src/mpk.ts @@ -9,11 +9,7 @@ async function ensureMxBuildDockerImageExists(mendixVersion: Version): Promise= 10 - ? "/tmp/mxbuild/modeler/mx create-module-package" - : "/tmp/mxbuild/modeler/mxutil.exe create-module-package", + versionMajor < 10 ? "/tmp/mxbuild/modeler/mxutil" : "/tmp/mxbuild/modeler/mx", + "create-module-package", excludeFilesRegExp ? `--exclude-files='${excludeFilesRegExp}'` : "", `/source/${projectFile}`, moduleName diff --git a/automation/utils/src/oss-readme.ts b/automation/utils/src/oss-readme.ts new file mode 100644 index 0000000000..1fb73035eb --- /dev/null +++ b/automation/utils/src/oss-readme.ts @@ -0,0 +1,12 @@ +import { globSync } from "glob"; + +export function findOssReadme(packageRoot: string, widgetName: string, version: string): string | undefined { + const readmeossPattern = `**/*${widgetName}__${version}__READMEOSS_*.html`; + + console.info(`Looking for READMEOSS file matching pattern: ${readmeossPattern}`); + + // Use glob to find files matching the pattern in package root + const matchingFiles = globSync(readmeossPattern, { cwd: packageRoot, absolute: true, ignore: "**/dist/**" }); + + return matchingFiles[0]; +} diff --git a/automation/utils/src/package-info.ts b/automation/utils/src/package-info.ts index 1e5e62d646..39d5fca1d5 100644 --- a/automation/utils/src/package-info.ts +++ b/automation/utils/src/package-info.ts @@ -86,7 +86,7 @@ export const PackageSchema = z.object({ appNumber: true }), repository: RepositorySchema, - testProject: TestProjectSchema + testProject: TestProjectSchema.optional() }); export const PublishedPackageSchema = PackageSchema.extend({ diff --git a/automation/utils/src/package-xml-v2/index.ts b/automation/utils/src/package-xml-v2/index.ts new file mode 100644 index 0000000000..a781e91ded --- /dev/null +++ b/automation/utils/src/package-xml-v2/index.ts @@ -0,0 +1,94 @@ +import { XMLBuilder, XMLParser } from "fast-xml-parser"; +import { readFile, writeFile } from "fs/promises"; +import { Version, VersionString } from "../version"; +import { ClientModulePackageFile } from "./schema"; + +export function xmlTextToXmlJson(xmlText: string | Buffer): unknown { + const parser = new XMLParser({ ignoreAttributes: false }); + return parser.parse(xmlText); +} + +export function xmlJsonToXmlText(xmlObject: any): string { + const builder = new XMLBuilder({ + ignoreAttributes: false, + format: true, + indentBy: " ", + suppressEmptyNode: true + }); + return builder + .build(xmlObject) + .replaceAll(/(<[^>]*?)\/>/g, "$1 />") // Add space before /> in self-closing tags + .replaceAll(/(<\?[^>]*?)\?>/g, "$1 ?>"); // Add space before ?> in XML declarations +} + +export interface ClientPackageXML { + name: string; + version: Version; + widgetFiles: string[]; + files: string[]; +} + +export async function readClientPackageXml(path: string): Promise { + return parseClientPackageXml(ClientModulePackageFile.passthrough().parse(xmlTextToXmlJson(await readFile(path)))); +} + +export async function writeClientPackageXml(path: string, data: ClientPackageXML): Promise { + await writeFile(path, xmlJsonToXmlText(buildClientPackageXml(data))); +} + +function parseClientPackageXml(xmlJson: ClientModulePackageFile): ClientPackageXML { + const clientModule = xmlJson?.package?.clientModule ?? {}; + const widgetFilesNode = clientModule.widgetFiles !== "" ? clientModule.widgetFiles?.widgetFile : undefined; + const filesNode = clientModule.files !== "" ? clientModule.files?.file : undefined; + + const extractPaths = (node: any): string[] => { + if (!node) return []; + if (Array.isArray(node)) { + return node.map((item: any) => item["@_path"]); + } + return [node["@_path"]]; + }; + + const name = clientModule["@_name"] ?? ""; + const versionString = clientModule["@_version"] ?? "1.0.0"; + + return { + name, + version: Version.fromString(versionString as VersionString), + widgetFiles: extractPaths(widgetFilesNode), + files: extractPaths(filesNode) + }; +} + +function buildClientPackageXml(clientPackage: ClientPackageXML): ClientModulePackageFile { + const toXmlNode = (arr: string[], tag: T) => { + if (arr.length === 0) return ""; + if (arr.length === 1) { + return { [tag]: { "@_path": arr[0] } }; + } + return { [tag]: arr.map(path => ({ "@_path": path })) }; + }; + + return { + "?xml": { + "@_version": "1.0", + "@_encoding": "utf-8" + }, + package: { + clientModule: { + widgetFiles: toXmlNode( + clientPackage.widgetFiles, + "widgetFile" + ) as ClientModulePackageFile["package"]["clientModule"]["widgetFiles"], + files: toXmlNode( + clientPackage.files, + "file" + ) as ClientModulePackageFile["package"]["clientModule"]["files"], + "@_name": clientPackage.name, + "@_version": clientPackage.version.format(), + "@_xmlns": "http://www.mendix.com/clientModule/1.0/" + }, + "@_xmlns": "http://www.mendix.com/package/1.0/" + } + }; +} diff --git a/automation/utils/src/package-xml-v2/properties-xml.ts b/automation/utils/src/package-xml-v2/properties-xml.ts new file mode 100644 index 0000000000..46c95dfcce --- /dev/null +++ b/automation/utils/src/package-xml-v2/properties-xml.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { xmlTextToXmlJson } from "./index"; +import { readFile } from "node:fs/promises"; + +export const PropertiesXMLFile = z.object({ + "?xml": z.object({ + "@_version": z.literal("1.0"), + "@_encoding": z.literal("utf-8") + }), + widget: z.object({ + "@_id": z.string(), + "@_xmlns": z.literal("http://www.mendix.com/widget/1.0/"), + "@_xmlns:xsi": z.literal("http://www.w3.org/2001/XMLSchema-instance") + }) +}); + +type PropertiesXMLFile = z.infer; + +export async function readPropertiesFile(filePath: string): Promise { + return PropertiesXMLFile.passthrough().parse(xmlTextToXmlJson(await readFile(filePath, "utf-8"))); +} diff --git a/automation/utils/src/package-xml-v2/schema.ts b/automation/utils/src/package-xml-v2/schema.ts new file mode 100644 index 0000000000..f37da9c932 --- /dev/null +++ b/automation/utils/src/package-xml-v2/schema.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +const FileTag = z.object({ + "@_path": z.string() +}); + +const FileNode = z.union([FileTag, FileTag.array()]); + +export const ModelerProjectPackageFile = z.object({ + "?xml": z.object({ + "@_version": z.literal("1.0"), + "@_encoding": z.literal("utf-8") + }), + package: z.object({ + "@_xmlns": z.literal("http://www.mendix.com/package/1.0/"), + modelerProject: z.object({ + "@_xmlns": z.literal("http://www.mendix.com/modelerProject/1.0/"), + module: z.object({ + "@_name": z.string() + }), + projectFile: z.object({ + "@_path": z.string() + }), + files: z.union([ + z.literal(""), + z.object({ + file: FileNode + }) + ]) + }) + }) +}); + +export type ModelerProjectPackageFile = z.infer; + +export const ClientModulePackageFile = z.object({ + "?xml": z.object({ + "@_version": z.literal("1.0"), + "@_encoding": z.literal("utf-8") + }), + package: z.object({ + "@_xmlns": z.literal("http://www.mendix.com/package/1.0/"), + clientModule: z.object({ + "@_name": z.string({ + required_error: "name attribute is required" + }), + "@_version": z.string({ + required_error: "version attribute is required" + }), + "@_xmlns": z.literal("http://www.mendix.com/clientModule/1.0/"), + + files: z.union([ + z.literal(""), + z.object({ + file: FileNode + }) + ]), + + widgetFiles: z.union([ + z.literal(""), + z.object({ + widgetFile: FileNode + }) + ]) + }) + }) +}); + +export type ClientModulePackageFile = z.infer; diff --git a/automation/utils/src/steps.ts b/automation/utils/src/steps.ts index 1c459891a7..bab6e93ca7 100644 --- a/automation/utils/src/steps.ts +++ b/automation/utils/src/steps.ts @@ -1,6 +1,5 @@ -import { writeFile, readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { dirname, join, parse, relative, resolve } from "path"; -import { fgYellow } from "./ansi-colors"; import { CommonBuildConfig, getModuleConfigs, @@ -13,7 +12,9 @@ import { copyMpkFiles, getMpkPaths } from "./monorepo"; import { createModuleMpkInDocker } from "./mpk"; import { ModuleInfo, PackageInfo, WidgetInfo } from "./package-info"; import { addFilesToPackageXml, PackageType } from "./package-xml"; -import { cp, ensureFileExists, exec, mkdir, popd, pushd, rm, unzip, zip, chmod } from "./shell"; +import { chmod, cp, ensureFileExists, exec, find, mkdir, popd, pushd, rm, unzip, zip } from "./shell"; +import chalk from "chalk"; +import { findOssReadme } from "./oss-readme"; type Step = (params: { info: Info; config: Config }) => Promise; @@ -49,8 +50,8 @@ export async function cloneTestProject({ info, config }: CommonStepParams): Prom const clone = process.env.CI ? cloneRepoShallow : cloneRepo; rm("-rf", config.paths.targetProject); await clone({ - remoteUrl: testProject.githubUrl, - branch: testProject.branchName, + remoteUrl: testProject!.githubUrl, + branch: testProject!.branchName, localFolder: config.paths.targetProject }); } @@ -187,6 +188,8 @@ export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise { + logStep("Add READMEOSS to mpk"); + + // Check that READMEOSS file exists in package root and find it by name pattern + const packageRoot = config.paths.package; + const widgetName = info.mxpackage.name; + const version = info.version.format(); + + // We'll search for files matching the name and version, ignoring timestamp + const readmeossFile = findOssReadme(packageRoot, widgetName, version); + + if (!readmeossFile) { + console.warn(`⚠️ READMEOSS file not found for ${widgetName} version ${version}.`); + console.warn(` Skipping READMEOSS addition to mpk.`); + return; + } + + console.info(`Found READMEOSS file: ${parse(readmeossFile).base}`); + + const mpk = config.output.files.modulePackage; + const mpkEntry = parse(mpk); + const target = join(mpkEntry.dir, "tmp"); + + rm("-rf", target); + + console.info("Unzip module mpk"); + await unzip(mpk, target); + chmod("-R", "a+rw", target); + + console.info(`Add READMEOSS file to ${mpkEntry.base}`); + // Copy the READMEOSS file to the target directory + cp(readmeossFile, target); + + console.info("Create module zip archive"); + await zip(target, mpk); + rm("-rf", target); +} + export async function moveModuleToDist({ info, config }: ModuleStepParams): Promise { logStep("Move module to dist"); @@ -210,9 +251,9 @@ export async function pushUpdateToTestProject({ info, config }: ModuleStepParams logStep("Push update to test project"); if (!process.env.CI) { - console.warn(fgYellow("You run script in non CI env")); - console.warn(fgYellow("Set CI=1 in your env if you want to push changes to remote test project")); - console.warn(fgYellow("Skip push step")); + console.warn(chalk.yellow("You run script in non CI env")); + console.warn(chalk.yellow("Set CI=1 in your env if you want to push changes to remote test project")); + console.warn(chalk.yellow("Skip push step")); return; } @@ -222,14 +263,14 @@ export async function pushUpdateToTestProject({ info, config }: ModuleStepParams const status = (await exec(`git status --porcelain`, { stdio: "pipe" })).stdout.trim(); if (status === "") { - console.warn(fgYellow("Nothing to commit")); - console.warn(fgYellow("Skip push step")); + console.warn(chalk.yellow("Nothing to commit")); + console.warn(chalk.yellow("Skip push step")); return; } await setLocalGitUserInfo(); await exec(`git add .`); - await exec(`git commit -m "Automated update for ${info.mxpackage.name} module"`); + await exec(`git commit -m "Automated update for ${info.mxpackage.name} module [${info.version}]"`); await exec(`git push origin`); popd(); } diff --git a/automation/utils/src/verify-widget-manifest.ts b/automation/utils/src/verify-widget-manifest.ts index 8511a50b48..eb7928b623 100644 --- a/automation/utils/src/verify-widget-manifest.ts +++ b/automation/utils/src/verify-widget-manifest.ts @@ -1,5 +1,5 @@ import { ClientModulePackageFile, readPackageXml } from "./package-xml"; -import { WidgetPackageSchema, WidgetPackage } from "./package-info"; +import { WidgetPackage, WidgetPackageSchema } from "./package-info"; type ParsedManifests = [WidgetPackage, ClientModulePackageFile]; diff --git a/docs/requirements/app-flow.md b/docs/requirements/app-flow.md new file mode 100644 index 0000000000..5d5c7faf08 --- /dev/null +++ b/docs/requirements/app-flow.md @@ -0,0 +1,67 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Pluggable Widget Lifecycle & Application Flow + +Understanding how a pluggable widget moves from development to usage in Mendix Studio Pro is essential. This document breaks down the flow of creating, registering, configuring, building, and integrating a widget into Mendix Studio Pro. + +## 1. Scaffold / Creation of a New Widget + +Developers typically scaffold a new widget using the Mendix Pluggable Widget Generator. This generator creates: + +- A package structure (e.g., `packages/pluggableWidgets/my-widget-web/`). +- Initial files: a TypeScript/TSX file for the React component, a corresponding XML configuration file, a README, and build configuration files. + +Using the generator ensures correct conventions in folder layout, naming, and sample code. + +## 2. Widget Registration via XML + +Every widget has an XML file (e.g., `MyWidget.xml`) that: + +- Defines a unique widget ID and name (following the namespace pattern). +- Specifies metadata for categorization in Studio Pro. +- Defines properties (inputs, outputs, events) including types and defaults. +- Includes system properties (e.g., Visibility, Tab index). + +Studio Pro reads this XML to register the widget. Errors in the XML (such as duplicate IDs) prevent proper registration. + +## 3. Development: Implementing and Configuring the Widget + +After scaffolding and XML configuration: + +- **Coding:** Implement the widget's functionality in a `.tsx` file using React. The component receives props corresponding to the XML-defined properties. +- **Styling:** Use appropriate CSS classes (preferably Atlas UI classes) rather than inline styles. +- **Configuration:** Test the widget by adding it to a page in Studio Pro and verifying that properties appear and function as defined. + +Live reload is used during development to quickly reflect changes. + +## 4. Build and Bundle + +Using pnpm (or npm), developers: + +- Run `pnpm install` to install dependencies. +- Set the `MX_PROJECT_PATH` to point to a Mendix test project. +- You can ask the name of the project, and then set `MX_PROJECT_PATH` dynamically, for example: `export MX_PROJECT_PATH="$HOME/Mendix/${PROJECT_NAME}"`. This way, whether you run `pnpm start` or `pnpm build`, the output is deployed to the correct Studio Pro project and we can immediately see the latest changes. +- Run `pnpm start` to build the widget using Rollup (compiling TypeScript and SCSS). The output is placed in the Mendix project's widget folder. + +Rollup handles bundling into an optimized, single JavaScript file. + +## 5. Integration into Mendix Studio Pro + +Once built: + +- **During Development:** The widget appears in Studio Pro's toolbox after synchronization. Developers can drag it onto pages and configure its properties. +- **Packaging:** For distribution, a production build generates an MPK (a ZIP package) that can be imported into any Mendix project. +- **At Runtime:** The Mendix Client instantiates the widget, passes the configured properties (as EditableValue, DynamicValue, ActionValue, etc.), and the widget renders its UI. + +## 6. Summary of the Flow + +1. **Design/Develop:** Write widget code and define properties in XML. +2. **Register:** XML registration makes the widget available in Studio Pro. +3. **Configure in App:** Drag the widget onto a page and set its properties. +4. **Build:** Compile using pnpm/Rollup. +5. **Run/Integrate:** Mendix client loads the widget with runtime data. +6. **Iteration:** Repeat as needed with live reload and synchronization. diff --git a/docs/requirements/backend-structure.md b/docs/requirements/backend-structure.md new file mode 100644 index 0000000000..fdab08c60f --- /dev/null +++ b/docs/requirements/backend-structure.md @@ -0,0 +1,39 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Widget Data Flow & Backend Integration + +Although pluggable widgets are primarily client-side components, they operate within the Mendix ecosystem. This document explains how data flows between a widget and the Mendix runtime, and how events are handled. + +## Widget XML Configuration and Mendix Runtime + +- **Property Types and Mendix Data:** + - **attribute:** Provided as an EditableValue object. + - **textTemplate:** Passed as a DynamicValue. + - **objects/object:** Provided as a ListValue or ObjectValue. + - **Simple types:** Passed as plain JS values. + - **action:** Provided as an ActionValue with methods like `execute()`. +- **Data Context:** Widgets may require a context object if placed within a Data View. +- **Offline Capable:** XML can mark widgets as offline capable, meaning they are designed to work without a network connection. + +## Data Flow: Reading and Updating Data + +- **Initial Data Loading:** On page load, Mendix supplies data via props (e.g., EditableValue, DynamicValue). Widgets should render loading states if data is not yet available. +- **Two-Way Data Binding:** For interactive widgets, changes are pushed back using methods like `EditableValue.setValue()`. +- **Events and Actions:** User-triggered events call ActionValue's `execute()` method (after checking `canExecute`) to run Mendix microflows or nanoflows. + +## Mendix Client and Widget Lifecycle + +- **Instantiation:** Mendix creates the widget component on page load and passes the required props. +- **Re-rendering:** Changes in Mendix data trigger re-rendering via updated props. +- **Destruction:** Widgets unmount when removed; cleanup (e.g., event listeners) should occur during unmount. +- **Communication with the Server:** Widgets trigger server-side logic indirectly via microflows or nanoflows, using data source properties when needed. + +## Example: DataGrid2 Interaction + +- A DataGrid widget receives a ListValue for data. +- Sorting or row click events trigger ActionValues that may call microflows. +- Updates to the data source result in re-rendering of the widget. diff --git a/docs/requirements/frontend-guidelines.md b/docs/requirements/frontend-guidelines.md new file mode 100644 index 0000000000..d660dd58ab --- /dev/null +++ b/docs/requirements/frontend-guidelines.md @@ -0,0 +1,43 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Frontend Guidelines for Mendix Pluggable Widgets + +This guide provides best practices for front-end development in Mendix pluggable widgets, focusing on styling, component structure, naming, and Atlas design system usage. The goal is to ensure consistent, maintainable widget development. + +## CSS and SCSS Styling Guidelines + +- **Use SCSS for Styles:** Write all widget styles in SCSS files to leverage variables, mixins, and integration with the overall theme. +- **No Inline Styles for Static Design:** Avoid using inline styles. Instead, assign CSS classes defined in SCSS or use Atlas classes. +- **Leverage Atlas UI Classes:** Utilize predefined Atlas classes (e.g., `btn`, `badge`) to ensure a consistent look. +- **Scoped vs Global Styles:** Prefix custom classes with the widget name (e.g., `.widget-combobox`) to avoid conflicts. +- **Responsive Design:** Use relative units and media queries to ensure the widget adapts gracefully to different screen sizes. +- **Avoid Deep Nesting:** Write clear, maintainable CSS without over-nesting or using `!important` unless absolutely necessary. +- **Design Properties and Conditional Classes:** Implement design properties via toggling classes based on user selections in Studio Pro. + +## Naming Conventions (Files, Classes, and Components) + +- **Component and File Names:** Use PascalCase for React component files (e.g., `ProgressBar.tsx`). +- **Widget Folder Naming:** Use lowercase names with hyphens ending with "web" (e.g., `badge-button-web`). +- **XML Keys and Attribute Names:** Use lowerCamelCase for XML property keys. +- **CSS Class Naming:** Follow a BEM-like convention for custom classes to maintain structure and clarity. + +## Component Best Practices + +- **Mendix Data API Usage:** Use Mendix API objects (EditableValue, ActionValue, etc.) correctly, checking conditions such as `canExecute` before calling actions. +- **State Management:** Use React state for UI-specific state and rely on Mendix props for persistent data. There are usages of Context and State management tools(mobx) +- **Performance Considerations:** Optimize renders, avoid heavy computations, and consider memoization. +- **Clean Up:** Ensure any event listeners or timers are cleaned up appropriately. +- **No Direct DOM Manipulation:** Use React's state and props to drive UI changes rather than direct DOM queries. +- **Accessibility:** Use semantic HTML, proper ARIA attributes, and ensure keyboard navigation is supported. + +## Using Atlas UI and Theming + +- **Consistent Look and Feel:** Ensure widgets integrate with Atlas UI by using default Atlas classes for common elements. +- **Custom Themes Support:** Build widgets so that they naturally adopt custom Atlas themes without hard-coded values. +- **Layout and Sizing:** Use flexible layouts (e.g., percentage widths, flexbox) that adapt to container sizes. +- **No Overriding Atlas Core Classes:** Do not override core Atlas classes; wrap widget elements if custom styling is needed. +- **Example – Consistent Button:** Use `` instead of custom-styled divs. diff --git a/docs/requirements/implementation-plan.md b/docs/requirements/implementation-plan.md new file mode 100644 index 0000000000..03c00c6ade --- /dev/null +++ b/docs/requirements/implementation-plan.md @@ -0,0 +1,170 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Implementation Plan for a New Pluggable Widget + +This document outlines a step-by-step plan to design, build, test, and integrate a new pluggable widget in Mendix. + +## 1. Planning and Requirements + +- **Define the Widget's Purpose:** Clearly state what the widget does, its name (e.g., "ProgressCircle"), its category, target data, and required properties (e.g., value, maximum, showLabel, onClick). +- **Check Existing Patterns:** Review similar widgets for best practices and to avoid duplicating functionality. + +## 2. Environment Setup + +- **Prerequisites:** Install Node.js (using the version specified in the repository), Mendix Studio Pro. +- **Folder Structure:** Create a new package under `packages/pluggableWidgets/` in the monorepo or use a standalone directory. + +4. Define Widget Properties in XML + Edit the generated XML file (e.g., ProgressCircle.xml) to define properties and property groups. + +Ensure property keys match the TypeScript props. + +Do not change the widget's unique ID unless necessary. + +5. Implement the React Component + Modify the generated .tsx file to match the XML-defined properties. + +Use Mendix API types (e.g., EditableValue, DynamicValue, ActionValue) correctly. + +Implement basic rendering and add error/loading states. + +Import SCSS for styling and use Atlas UI classes. + +6. Build and Run in Studio Pro + Set the MX_PROJECT_PATH environment variable to your test project's directory, you can ask what the project name running in Studio Pro is so that we can se the MX_PROJECT_PATH. The path is: /Users/rahman.unver/Mendix/ProjectName, this path is always same for the macOS version of Studio Pro. For Windows version, our path is /Volumes/[C] Windows11/Users/Rahman.Unver/Documents/ProjectName. example setting: + Windows: + +- export MX_PROJECT_PATH=/Volumes/[C] Windows11/Users/Rahman.Unver/Documents/DocumentViewerWidget +- export MX_PROJECT_PATH=/Users/rahman.unver/Mendix/RichTextTest + +Run pnpm start (or npm start) to build and watch for changes. + +In Studio Pro, synchronize the app directory to load the widget. + +Place the widget on a test page and configure properties. + +7. Iterative Development + Develop and test iteratively. + +Use browser developer tools for debugging. + +Adjust XML and TS code as needed when adding new properties or handling edge cases. + +8. Testing and Validation + Write unit tests for critical logic using Jest, React Testing Library mainly. + +Perform UI/functional testing within a Mendix project. + +Test responsiveness, performance, and edge cases. + +9. Documentation and Metadata + Update the widget's README with usage instructions and limitations. + +Ensure XML descriptions and property captions are clear. + +Optionally, add an icon for the widget. + +10. Packaging and Sharing + Run npm run build to produce an MPK file. + +Test the MPK in a fresh Mendix project. + +Update version numbers and changelogs before distribution. + +Common Gotchas: + +Ensure XML and TypeScript props match exactly. + +Install all third-party dependencies. + +Maintain case sensitivity in IDs and keys. + +Clean up event listeners to avoid memory leaks. + +Ensure compatibility with the target Mendix version. + +### Github PR Template + + + + + + +### Pull request type + + + + + + + + + + + + + + + +--- + + + +### Description + + + + + + +Keep this template in mind if I ever ask you to open a PR through command console, Github CLI or similar. diff --git a/docs/requirements/project-requirements-document.md b/docs/requirements/project-requirements-document.md new file mode 100644 index 0000000000..5879777df4 --- /dev/null +++ b/docs/requirements/project-requirements-document.md @@ -0,0 +1,43 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Web Widgets Repository – Product Requirements + +## Purpose and Background + +The Mendix Web Widgets repository is the home of all Mendix platform-supported pluggable web widgets and related modules. Its primary purpose is to provide a unified collection of reusable UI components that Mendix app developers can drop into their apps, ensuring consistency and high quality across projects. These widgets are built with Mendix's Pluggable Widgets API (introduced in Mendix 8 and enhanced in Mendix 9) and use modern web technologies (React, TypeScript, SASS) under the hood. Mendix pluggable widgets are essentially custom React components that integrate seamlessly into Mendix Studio Pro as if they were native widgets. By open-sourcing this repository, Mendix enables developers to learn from real examples and even contribute enhancements. + +## Goals and Objectives + +- **Comprehensive Widget Library:** Offer a broad range of common UI elements (grids, inputs, charts, etc.) as pluggable widgets so developers rarely need to build widgets from scratch. Each widget addresses a specific need (e.g., data grid for tabular data, color picker for color selection) with Mendix best practices in mind. +- **Consistency and Quality:** Ensure all widgets follow a consistent design language (Atlas UI design system) and coding standards. This makes the AI assistant's suggestions consistent with how a Mendix team member would implement features. Widgets adhere to Mendix's style and UX conventions, leading to a uniform experience across apps. +- **Ease of Use:** Simplify the usage of custom widgets in Mendix Studio Pro. Widgets appear in Studio Pro's toolbox with proper categorization (e.g., "Buttons", "Containers") and have intuitive properties editors defined via XML. Mendix developers can drag & drop these widgets and configure properties without writing code. +- **Reusability and Extensibility:** Provide widgets that are generic yet flexible. Many widgets offer customization via properties or design "appearance" options (often powered by Atlas classes or helper classes). For example, the Badge Button widget can be styled via preset classes (primary, info, warning, etc.) rather than creating new widgets for each style. +- **Maintainability:** As part of the platform, these widgets are maintained alongside Mendix updates. The repository is structured to facilitate testing (unit tests, E2E tests) and continuous integration. This ensures widgets remain compatible with new Mendix versions and quality regressions are caught early. + +## Target Users + +- **Primary:** Mendix application developers (both citizen and professional developers) using Mendix Studio Pro. They benefit from a rich set of ready-made widgets that can be configured visually. +- **Secondary:** Mendix R&D and community contributors. Mendix's engineering team maintains official widgets, and community developers can reference the code to learn conventions or contribute improvements. In this context, the AI assistant "learns" from these docs to help developers. + +## Scope and Widget Coverage + +This repository covers pluggable web widgets for Mendix (not native mobile widgets). The scope includes: + +- **Form and Input Widgets:** E.g., Combo Box, Checkbox Set Selector, Date Picker – enhancing Mendix's default input components. +- **Display and Formatting Widgets:** E.g., Badge/Badge Button, Format String, Image Viewer – presenting data in formatted ways. +- **Containers and Layout Widgets:** E.g., Fieldset, Accordion, Tab widgets – for grouping and layout. +- **Data Display Widgets:** Most notably, Data Grid 2 for displaying records in a table with features like sorting and filtering. +- **Navigation/Actions Widgets:** E.g., Action Button and Events widget – connecting UI interactions to Mendix logic. +- **Integration & Utility Widgets:** E.g., HTML/JavaScript Snippet, Google Maps, Charts – extending Mendix with external integrations. + +## Design System Alignment + +All widgets align with Atlas UI, Mendix's design system. They use Atlas styling conventions (spacing, colors, fonts) and support design properties for easy styling changes. Widgets automatically adopt custom Atlas-based themes through standard class names and variables. + +## Conclusion + +The Web Widgets repository is critical to the Mendix ecosystem—bridging low-code ease with pro-code flexibility. It empowers developers with ready components while ensuring consistency and quality. For Cursor's AI assistant, these guidelines and conventions provide the necessary context to generate answers and code that align with Mendix widget development. diff --git a/docs/requirements/tech-stack.md b/docs/requirements/tech-stack.md new file mode 100644 index 0000000000..dcebc786c1 --- /dev/null +++ b/docs/requirements/tech-stack.md @@ -0,0 +1,40 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Technology Stack and Project Structure + +The Mendix pluggable widgets ecosystem leverages a modern web development tech stack combined with Mendix-specific tools and configurations. This document outlines the key technologies and the project structure. + +## Core Technologies Used + +- **TypeScript:** All widgets are written in TypeScript (.tsx files) to enforce type safety and improve maintainability. +- **React:** Widgets are implemented as React components, enabling a declarative UI model and rich state management. +- **SASS/SCSS:** Styling is done with SCSS, allowing use of variables, nesting, and integration with Atlas UI for theming. +- **Rollup:** The bundler used to compile widget code into a single optimized file. Rollup handles TypeScript, JSX, and SCSS while performing tree-shaking. +- **NPM (pnpm):** pnpm is used for package management, ensuring efficient dependency handling and consistent build environments. +- **Jest:** Used for unit testing widget logic and components. +- **RTL(react-testing-library):** Used for unit testing widget logic and components. +- **Linting/Formatting:** ESLint and Prettier enforce code style consistency. + +## Monorepo Structure and Packaging + +- **Packages Folder:** All widget code resides under `packages/`, typically in `packages/pluggableWidgets/` with each widget in its own folder (e.g., `badge-button-web`, `datagrid-web`). +- **Shared Modules:** Common code and assets are stored in `packages/shared/`. +- **Modules and Actions:** Additional Mendix modules and JavaScript actions are organized in separate folders. +- **Atlas Integration:** Widgets reference Atlas UI classes for a consistent design without duplicating styles. +- **Configuration Files:** Root-level configuration files (e.g., lerna.json, package.json, .nvmrc) ensure consistent tooling. +- **Build Scripts:** Each widget package includes scripts for building, starting, and testing, using @mendix/pluggable-widgets-tools. +- **Testing Setup:** Unit tests reside in widget folders (e.g., `src/__tests__/`) and can be run collectively via pnpm test. + +## Configuration and Code Standards + +- **ESLint and Prettier:** Enforce code quality and style consistency. +- **Naming Conventions:** + - React component files use PascalCase. + - Widget folders use lowercase with hyphens ending with “-web”. + - XML property keys use lowerCamelCase. +- **Atlas Styling Conventions:** Prefer Atlas UI classes over custom inline styles. +- **Versioning and Backward Compatibility:** Widgets follow semantic versioning and maintain compatibility with Mendix platform changes. diff --git a/docs/requirements/widget-to-module.md b/docs/requirements/widget-to-module.md new file mode 100644 index 0000000000..390857da4d --- /dev/null +++ b/docs/requirements/widget-to-module.md @@ -0,0 +1,149 @@ +# Converting a Pluggable Widget into a Mendix **Module** + +This guide documents a repeatable workflow for converting any pluggable widget package (for example, **@mendix/calendar-web**) into a Mendix **Module** package (for example, **@mendix/calendar**). The resulting module bundles pages, domain model, microflows and the widget itself. The steps below align with the automation scripts located in `automation/utils` within this repository. + +--- + +## 1. Terminology + +| Term | Purpose | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| _Widget package_ | `.mpk` produced by Rollup in `packages/pluggableWidgets/-web`; contains only `` (JavaScript, XML, SCSS). | +| _Module package_ | `.mpk` exported by Studio Pro; contains `` (pages, microflows, domain model **plus** embedded widget mpk). | +| **Authoring app** | Your working project where you design the pages/microflows for the module. | +| **Host / test app** | A minimal blank project used by CI to (re)export the module; committed to `mendix/testProjects` repo. | + +--- + +## 2. Prerequisites + +- Node + `pnpm install` executed in the mono-repo. +- **@mendix/_widget_-web** already builds (`pnpm run build`). +- GitHub Personal-Access Token saved as secret `GH_PAT` for release workflow. + +--- + +## 3. Create / Export the Module in Studio Pro + +1. **Authoring app**: make sure everything lives inside one module (e.g. _Calendar_). +2. Right–click that module → _Export module package…_ → save `Calendar.mpk`. + _This file contains pages, flows, entities and the widget placeholder._ + +--- + +## 4. Prepare the Host Test Project + +1. Start a **Blank App** in Studio Pro (or `mx create-project`). +2. _App → Import module package…_ and select the `Calendar.mpk` from step 3. +3. Optional: add a navigation item to a Calendar page so the app runs out-of-box. +4. Close Studio Pro. +5. In terminal: + ```bash + cd + git init + git switch -c calendar-web # branch must match package.json + git remote add origin https://github.com/mendix/testProjects.git + echo "/deployment/\n/theme-cache/" > .gitignore + git add . + git commit -m "Initial Calendar host project" + git push -u origin calendar-web # or pull –rebase first if branch exists + ``` + +--- + +## 5. Scaffold the Module Package in the Mono-repo + +``` +packages/modules/calendar/ + ├─ package.json ← see template below + ├─ CHANGELOG.md + ├─ LICENSE ← Apache 2.0 copy + ├─ .prettierrc.js + └─ scripts/ + ├─ build.ts + ├─ push-update.ts + ├─ release.ts + └─ tsconfig.json +``` + +### `package.json` highlights + +```jsonc +{ + "name": "@mendix/calendar", + "mxpackage": { + "type": "module", + "name": "Calendar", + "mpkName": "Calendar.mpk", + "dependencies": ["@mendix/calendar-web"] + }, + "moduleFolderNameInModeler": "calendar", // themesource/javascriptsource + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "calendar-web" + }, + "scripts": { + "build:module": "ts-node --project scripts/tsconfig.json scripts/build.ts", + "release:module": "ts-node --project scripts/tsconfig.json scripts/release.ts" + } +} +``` + +Scripts are copied from `packages/modules/file-uploader/scripts/`. + +--- + +## 6. Local Verification + +```bash +# build the widget first +pnpm --filter @mendix/calendar-web run build + +# Option A: point to a running Studio Pro project +export MX_PROJECT_PATH="$HOME/Mendix/CalendarHost" + +# Option B: rely on tests/testProject folder +# (create it or let cloneTestProject do it in release script) + +pnpm --filter @mendix/calendar run build:module +``` + +The command clones (or uses `MX_PROJECT_PATH`) and copies `com.mendix.widget.web.calendar.mpk` into `widgets/`. Open the project in Studio Pro and run. + +--- + +## 7. Produce the Distributable MPK + +```bash +pnpm --filter @mendix/calendar run release:module +``` + +Pipeline steps (see `automation/utils/src/steps.ts`): + +1. `removeDist`  clean old output +2. `cloneTestProject` clone branch `calendar-web` +3. `writeModuleVersion` / `copyModuleLicense` +4. `copyWidgetsToProject` add fresh widget mpk +5. `createModuleMpk` export Calendar module via _mxbuild_ +6. `addWidgetsToMpk` embed widget MPK in module MPK +7. `moveModuleToDist` place under `dist//Calendar.mpk` + +Upload the resulting MPK to the Mendix Marketplace. + +--- + +## 8. CI / GitHub Actions + +• `.github/workflows/CreateGitHubRelease.yml` uses `${{ secrets.GH_PAT }}` to create a release and attach the MPK asset. Set that PAT in _Repo → Settings → Secrets → Actions_. + +--- + +## 9. Quick Checklist + +✔ Authoring module exported → `Calendar.mpk` +✔ Host project committed to `testProjects` (`calendar-web` branch) +✔ `@mendix/calendar` module package with correct metadata & scripts +✔ Local `build:module` works +✔ `release:module` produces `dist/Calendar.mpk` + +You now have a fully-packaged module ready for Marketplace users to drag-and-drop without extra configuration. diff --git a/package.json b/package.json index cd6f7ca32d..239832a19f 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,19 @@ "create-translation": "turbo run create-translation", "publish-marketplace": "turbo run publish-marketplace", "version": "pnpm --filter @mendix/automation-utils run version", - "changelog": "pnpm --filter @mendix/automation-utils run changelog" + "changelog": "pnpm --filter @mendix/automation-utils run changelog", + "prepare-release": "pnpm --filter @mendix/automation-utils run prepare-release", + "postinstall": "turbo run agent-rules" }, "devDependencies": { "husky": "^8.0.3", - "turbo": "^1.10.14" + "turbo": "^2.5.4" }, "engines": { "node": ">=22", - "pnpm": ">=10.7.1" + "pnpm": "10.15.0" }, - "packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912", + "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b", "pnpm": { "peerDependencyRules": { "allowedVersions": { @@ -39,7 +41,7 @@ ] }, "overrides": { - "@mendix/pluggable-widgets-tools": "10.21.0", + "@mendix/pluggable-widgets-tools": "10.21.2", "react": "^18.0.0", "react-dom": "^18.0.0", "prettier": "3.5.3", @@ -54,20 +56,29 @@ "jest-environment-jsdom": "^29.7.0", "json5@1.x": ">=1.0.2", "json5@0.x": ">=1.0.2", - "@codemirror/view": "^6.34.2", + "@codemirror/view": "^6.38.1", "enzyme>cheerio": "1.0.0-rc.10", "ts-node": "10.9.2", - "react-big-calendar@1>clsx": "2.1.1" + "react-big-calendar@1>clsx": "2.1.1", + "typescript": ">5.8.0" }, "patchedDependencies": { "react-big-calendar@0.19.2": "patches/react-big-calendar@0.19.2.patch", "mobx@6.12.3": "patches/mobx@6.12.3.patch", "mobx-react-lite@4.0.7": "patches/mobx-react-lite@4.0.7.patch", "mime-types": "patches/mime-types.patch", - "rc-trigger": "patches/rc-trigger.patch" + "rc-trigger": "patches/rc-trigger.patch", + "react-dropzone": "patches/react-dropzone.patch" }, "onlyBuiltDependencies": [ + "@swc/core", "canvas" + ], + "ignoredBuiltDependencies": [ + "@parcel/watcher", + "core-js", + "es5-ext" ] - } + }, + "prettier": "@mendix/prettier-config-web-widgets" } diff --git a/packages/customWidgets/calendar-custom-web/package.json b/packages/customWidgets/calendar-custom-web/package.json index 4867658e4e..2d0544705f 100644 --- a/packages/customWidgets/calendar-custom-web/package.json +++ b/packages/customWidgets/calendar-custom-web/package.json @@ -38,7 +38,7 @@ "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2", + "classnames": "^2.5.1", "date-arithmetic": "^3.1.0", "loader-utils": "1.4.2", "moment": "^2.30.1", diff --git a/packages/customWidgets/calendar-custom-web/src/components/Button.ts b/packages/customWidgets/calendar-custom-web/src/components/Button.ts index 570edfef05..8b4d0b6dd5 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/Button.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/Button.ts @@ -1,4 +1,4 @@ -import { ReactNode, createElement, FunctionComponent } from "react"; +import { createElement, FunctionComponent, ReactNode } from "react"; import classNames from "classnames"; export interface ButtonProps { diff --git a/packages/customWidgets/calendar-custom-web/src/components/Calendar.ts b/packages/customWidgets/calendar-custom-web/src/components/Calendar.ts index 11144145e7..dc301c101d 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/Calendar.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/Calendar.ts @@ -1,4 +1,4 @@ -import { CSSProperties, Component, ReactChild, createElement, ReactNode } from "react"; +import { Component, createElement, CSSProperties, ReactChild, ReactNode } from "react"; import { Alert } from "./Alert"; import { Container, Style } from "../utils/namespaces"; diff --git a/packages/customWidgets/calendar-custom-web/src/components/CalendarLoader.ts b/packages/customWidgets/calendar-custom-web/src/components/CalendarLoader.ts index 5f4eb621dd..1ad5d2a87b 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/CalendarLoader.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/CalendarLoader.ts @@ -1,4 +1,4 @@ -import { ReactElement, createElement, FunctionComponent } from "react"; +import { createElement, FunctionComponent, ReactElement } from "react"; import "../ui/CalendarLoader.scss"; export const CalendarLoader: FunctionComponent = () => diff --git a/packages/customWidgets/calendar-custom-web/src/components/SizeContainer.ts b/packages/customWidgets/calendar-custom-web/src/components/SizeContainer.ts index 15b4561fc2..f322336ac0 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/SizeContainer.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/SizeContainer.ts @@ -1,4 +1,4 @@ -import { CSSProperties, createElement, FunctionComponent, PropsWithChildren } from "react"; +import { createElement, CSSProperties, FunctionComponent, PropsWithChildren } from "react"; import classNames from "classnames"; export type HeightUnitType = "percentageOfWidth" | "percentageOfParent" | "pixels"; diff --git a/packages/customWidgets/calendar-custom-web/src/components/Toolbar.ts b/packages/customWidgets/calendar-custom-web/src/components/Toolbar.ts index c010a5fa7a..a8d9989e87 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/Toolbar.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/Toolbar.ts @@ -1,4 +1,4 @@ -import { ReactNode, createElement } from "react"; +import { createElement, ReactNode } from "react"; import classNames from "classnames"; import Toolbar from "react-big-calendar/lib/Toolbar"; import { Container, Style } from "../utils/namespaces"; diff --git a/packages/customWidgets/calendar-custom-web/src/components/__tests__/Alert.spec.ts b/packages/customWidgets/calendar-custom-web/src/components/__tests__/Alert.spec.ts index bd06676051..92f587a443 100644 --- a/packages/customWidgets/calendar-custom-web/src/components/__tests__/Alert.spec.ts +++ b/packages/customWidgets/calendar-custom-web/src/components/__tests__/Alert.spec.ts @@ -1,5 +1,5 @@ import { shallow, ShallowWrapper } from "enzyme"; -import { ReactChild, createElement } from "react"; +import { createElement, ReactChild } from "react"; import { Alert, AlertProps } from "../Alert"; diff --git a/packages/customWidgets/signature-web/package.json b/packages/customWidgets/signature-web/package.json index 685fea2e82..fc6557ddce 100644 --- a/packages/customWidgets/signature-web/package.json +++ b/packages/customWidgets/signature-web/package.json @@ -36,7 +36,7 @@ "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2", + "classnames": "^2.5.1", "react-resize-detector": "^9.1.1", "signature_pad": "4.0.0" }, diff --git a/packages/customWidgets/signature-web/src/components/Grid.tsx b/packages/customWidgets/signature-web/src/components/Grid.tsx index 8eb490875a..da73b64615 100644 --- a/packages/customWidgets/signature-web/src/components/Grid.tsx +++ b/packages/customWidgets/signature-web/src/components/Grid.tsx @@ -1,4 +1,4 @@ -import { FC, createElement } from "react"; +import { createElement, FC } from "react"; export interface GridBackgroundProps { gridCellWidth: number; diff --git a/packages/customWidgets/signature-web/src/components/SizeContainer.ts b/packages/customWidgets/signature-web/src/components/SizeContainer.ts index 1e11e307d9..ed414f7487 100644 --- a/packages/customWidgets/signature-web/src/components/SizeContainer.ts +++ b/packages/customWidgets/signature-web/src/components/SizeContainer.ts @@ -1,4 +1,4 @@ -import { CSSProperties, FC, createElement, PropsWithChildren } from "react"; +import { createElement, CSSProperties, FC, PropsWithChildren } from "react"; import classNames from "classnames"; export type HeightUnitType = "percentageOfWidth" | "percentageOfParent" | "pixels"; diff --git a/packages/customWidgets/signature-web/src/components/__tests__/Alert.spec.ts b/packages/customWidgets/signature-web/src/components/__tests__/Alert.spec.ts index f1cb174aa3..da2e899332 100644 --- a/packages/customWidgets/signature-web/src/components/__tests__/Alert.spec.ts +++ b/packages/customWidgets/signature-web/src/components/__tests__/Alert.spec.ts @@ -1,5 +1,5 @@ import { shallow, ShallowWrapper } from "enzyme"; -import { ReactChild, createElement } from "react"; +import { createElement, ReactChild } from "react"; import { Alert, AlertProps } from "../Alert"; diff --git a/packages/modules/calendar/.prettierrc.js b/packages/modules/calendar/.prettierrc.js new file mode 100644 index 0000000000..0892704ab0 --- /dev/null +++ b/packages/modules/calendar/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require("@mendix/prettier-config-web-widgets"); diff --git a/packages/modules/calendar/CHANGELOG.md b/packages/modules/calendar/CHANGELOG.md new file mode 100644 index 0000000000..01088a8176 --- /dev/null +++ b/packages/modules/calendar/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] Calendar - 2025-08-12 + +### Added + +- Initial version of Calendar module. + +### [2.0.0] Calendar + +#### Breaking changes + +- Initial version of Calendar pluggable widget. + +- Upgrading from any v1.x to v2.0.0 preview requires re-configuring the widget in Studio Pro. The property panel has been reorganised (e.g. View settings, Custom work-week options) and missing/renamed properties will be reset to their defaults. After installing v2.0.0 open each Calendar instance, review the settings and re-select the desired values. diff --git a/packages/modules/calendar/LICENSE b/packages/modules/calendar/LICENSE new file mode 100644 index 0000000000..8c705ebe13 --- /dev/null +++ b/packages/modules/calendar/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Mendix Technology B.V. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/modules/calendar/README.md b/packages/modules/calendar/README.md new file mode 100644 index 0000000000..6367f0362b --- /dev/null +++ b/packages/modules/calendar/README.md @@ -0,0 +1,18 @@ +# Calendar module + +This module bundles the **Calendar** pluggable widget together with sample pages, domain model, and helper flows so that app builders can drag-and-drop a fully working calendar including _New/Edit Event_ dialog. + +## Contents + +- Calendar widget (`@mendix/calendar-web`) +- Pages: `Calendar_Overview`, `Calendar_New Edit Event` +- Microflows for creating, editing and persisting events +- Domain model for `CalendarEvent` entity + +## Usage + +1. Import the module `.mpk` file into your Mendix project. +2. Drag the _Calendar_Overview_ page into your navigation or use the _Calendar_ page layout. +3. Customize the microflows or replace the `Event` entity with your own via associations. + +For full documentation of widget properties see the [Calendar widget docs](https://docs.mendix.com/appstore/widgets/calendar). diff --git a/packages/modules/calendar/package.json b/packages/modules/calendar/package.json new file mode 100644 index 0000000000..54581e899f --- /dev/null +++ b/packages/modules/calendar/package.json @@ -0,0 +1,47 @@ +{ + "name": "@mendix/calendar", + "moduleName": "Calendar module", + "version": "2.0.0", + "copyright": "© Mendix Technology BV 2025. All rights reserved.", + "license": "Apache-2.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/mendix/web-widgets.git" + }, + "mxpackage": { + "type": "module", + "name": "Calendar", + "mpkName": "Calendar.mpk", + "dependencies": [ + "@mendix/calendar-web" + ] + }, + "moduleFolderNameInModeler": "calendar", + "marketplace": { + "minimumMXVersion": "10.22.0.68245", + "appName": "Calendar", + "appNumber": 107954 + }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "calendar-web" + }, + "scripts": { + "build:module": "ts-node --project scripts/tsconfig.json scripts/build.ts", + "create-gh-release": "rui-create-gh-release", + "publish-marketplace": "rui-publish-marketplace", + "push-update": "ts-node --project scripts/tsconfig.json scripts/push-update.ts", + "release:module": "ts-node --project scripts/tsconfig.json scripts/release.ts", + "update-changelog": "rui-update-changelog-module", + "verify": "rui-verify-package-format" + }, + "dependencies": { + "@mendix/calendar-web": "workspace:*" + }, + "devDependencies": { + "@mendix/automation-utils": "workspace:*", + "@mendix/prettier-config-web-widgets": "workspace:*", + "cross-env": "^7.0.3" + } +} diff --git a/packages/modules/calendar/scripts/build.ts b/packages/modules/calendar/scripts/build.ts new file mode 100644 index 0000000000..c40fded8b0 --- /dev/null +++ b/packages/modules/calendar/scripts/build.ts @@ -0,0 +1,13 @@ +import { runModuleSteps, copyWidgetsToProject } from "@mendix/automation-utils/steps"; + +async function main(): Promise { + await runModuleSteps({ + packagePath: process.cwd(), + steps: [copyWidgetsToProject] + }); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/modules/calendar/scripts/push-update.ts b/packages/modules/calendar/scripts/push-update.ts new file mode 100644 index 0000000000..a593a1bcfd --- /dev/null +++ b/packages/modules/calendar/scripts/push-update.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env ts-node-script + +import { pushUpdateToTestProject, runModuleSteps } from "@mendix/automation-utils/steps"; + +async function main(): Promise { + await runModuleSteps({ + packagePath: process.cwd(), + steps: [pushUpdateToTestProject] + }); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/modules/calendar/scripts/release.ts b/packages/modules/calendar/scripts/release.ts new file mode 100644 index 0000000000..6472347c37 --- /dev/null +++ b/packages/modules/calendar/scripts/release.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env ts-node-script + +import { + addREADMEOSSToMpk, + addWidgetsToMpk, + cloneTestProject, + copyModuleLicense, + copyWidgetsToProject, + createModuleMpk, + moveModuleToDist, + removeDist, + runModuleSteps, + writeModuleVersion +} from "@mendix/automation-utils/steps"; + +async function main(): Promise { + await runModuleSteps({ + packagePath: process.cwd(), + steps: [ + removeDist, + cloneTestProject, + writeModuleVersion, + copyModuleLicense, + copyWidgetsToProject, + createModuleMpk, + addWidgetsToMpk, + addREADMEOSSToMpk, + moveModuleToDist + ] + }); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/modules/calendar/scripts/tsconfig.json b/packages/modules/calendar/scripts/tsconfig.json new file mode 100644 index 0000000000..eeb4a6cc48 --- /dev/null +++ b/packages/modules/calendar/scripts/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@mendix/automation-utils/tsconfig" +} diff --git a/packages/modules/data-widgets/CHANGELOG.md b/packages/modules/data-widgets/CHANGELOG.md index ae43ffee6a..6b0b407a13 100644 --- a/packages/modules/data-widgets/CHANGELOG.md +++ b/packages/modules/data-widgets/CHANGELOG.md @@ -6,6 +6,186 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [3.4.0] DataWidgets - 2025-09-12 + +### Added + +- We added a new JavaScript action to clear the selection in the Data grid 2 and Gallery widgets. + +### [3.4.0] DatagridDateFilter + +#### Fixed + +- We fixed label issues reported by Axe a11y tools + +### [3.4.0] DatagridDropdownFilter + +#### Fixed + +- We fixed label issues reported by Axe a11y tool + +### [3.4.0] DatagridNumberFilter + +#### Changed + +- Internal improvements. + +### [3.4.0] DatagridTextFilter + +#### Changed + +- Internal improvements. + +### [3.4.0] Datagrid + +#### Fixed + +- We fixed an issue where the datagrid's horizontal scrollbar would unexpectedly jump to the right when the column selector was enabled. + +### [3.4.0] Gallery + +#### Fixed + +- We fixed an issue where the column count was not reflected properly in the preview mode. + +#### Added + +- Added a 'horizontal divider' option to Borders design property for Gallery list items, allowing improved visual separation and customization. + +### [3.4.0] TreeNode + +#### Changed + +- Internal improvements. + +## [3.3.0] DataWidgets - 2025-08-28 + +### [3.3.0] DatagridDropdownFilter + +#### Fixed + +- We implemented ellipsis truncation to resolve option caption overflow issues. + +- We fixed an issue where tooltips were not displayed correctly when hovering over options. + +### [3.3.0] Datagrid + +#### Added + +- We implemented a new property to show a refresh indicator. With the refresh indicator, any datasource change shows a progress bar on top of Datagrid 2. + +- We added a selection count display that shows the number of selected rows in the grid footer. The count appears automatically when items are selected and supports customizable text formats for singular and plural forms via the new "Row count singular" and "Row count plural" properties. + +### [3.3.0] Gallery + +#### Added + +- We implemented a new property to show a refresh indicator. With the refresh indicator, any datasource change shows a progress bar on top of Gallery. + +## [3.2.0] DataWidgets - 2025-08-18 + +### [3.2.0] DatagridDateFilter + +#### Changed + +- Internal improvements. + +### [3.2.0] DatagridDropdownFilter + +#### Changed + +- Internal improvements. + +### [3.2.0] DatagridNumberFilter + +#### Changed + +- Internal improvements. + +### [3.2.0] DatagridTextFilter + +#### Changed + +- Internal improvements. + +### [3.2.0] Datagrid + +#### Changed + +- We removed all metadata stored in xpath to improve integration with other services. + +### [3.2.0] Gallery + +#### Changed + +- We removed all metadata stored in xpath to improve integration with other services. + +## [3.1.1] DataWidgets - 2025-08-05 + +### [3.0.4] DatagridDropdownFilter + +#### Fixed + +- We enhanced dropdown widget customization by separating texts for empty selection, empty option caption, and input placeholder. This change allows for more granular control over the widget's text configurations. + +- We improved dropdown widget usability with enhanced keyboard navigation, visual feedback, and interaction behavior. + +#### Breaking changes + +- Text configurations for empty option, empty selection, and input placeholder need to be reviewed and reconfigured. + +### [3.0.1] Datagrid + +#### Fixed + +- We fixed an issue where the filter values were restored from previously stored personalized configuration even when Store filters was set to No. + +## [3.1.0] DataWidgets - 2025-07-24 + +### Fixed + +- We fixed an issue where datagrid styling being overwritten by table styling from atlas core. + +- We fixed an issue where horizontal scrolling failed to show if width is too big. + +### [3.0.3] DatagridDropdownFilter + +#### Fixed + +- We fixed an issue where the "Use Lazy Load" flag was not being read properly. This caused the widget to always load lazily in association mode. + +### [3.1.0] Gallery + +#### Added + +- Similar to Data Grid 2, Gallery now can store filters and sort configurations in an attribute or browser. These new settings can be found in the "Personalization" tab. + +#### Fixed + +- We fixed an issue where the default sort order was not being applied. + +## [3.0.2] DataWidgets - 2025-07-10 + +### [3.0.2] DatagridDropdownFilter + +#### Added + +- We added back missing expression configuration for caption after the major upgrade. + +## [3.0.1] DataWidgets - 2025-07-01 + +### [3.0.1] Gallery + +#### Fixed + +- We fixed an issue where gallery widget wasn't following screen breakpoints defined in custom-variables file. + +## [3.0.0] DataWidgets - 2025-06-27 + +### Breaking changes + +- Grid-wide filtering is now configurable directly within the filter widget settings, offering greater flexibility and control over filtering in both the Data Grid and Gallery widgets. Existing configurations using grid-wide filtering will be automatically converted to the new setup when updating the widget. + ## [2.32.1] DataWidgets - 2025-05-28 ### [2.30.6] Datagrid diff --git a/packages/modules/data-widgets/LICENSE b/packages/modules/data-widgets/LICENSE index 51dfbf50dc..8c705ebe13 100644 --- a/packages/modules/data-widgets/LICENSE +++ b/packages/modules/data-widgets/LICENSE @@ -1,4 +1,4 @@ -Apache License + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +186,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Mendix Technology BV + Copyright 2022 Mendix Technology B.V. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -198,4 +198,4 @@ Apache License distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/packages/modules/data-widgets/eslint.config.mjs b/packages/modules/data-widgets/eslint.config.mjs new file mode 100644 index 0000000000..ef9f238716 --- /dev/null +++ b/packages/modules/data-widgets/eslint.config.mjs @@ -0,0 +1,8 @@ +import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs"; + +export default [ + ...config, + { + ignores: ["tests", "dist", "src/javascriptsource"] + } +]; diff --git a/packages/modules/data-widgets/package.json b/packages/modules/data-widgets/package.json index 8958513ccd..d227f0a95e 100644 --- a/packages/modules/data-widgets/package.json +++ b/packages/modules/data-widgets/package.json @@ -1,7 +1,8 @@ { "name": "@mendix/data-widgets", "moduleName": "Data Widgets", - "version": "2.32.1", + "version": "3.4.0", + "description": "Data Widgets module containing a set of widgets to display data in various ways.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", "private": true, @@ -27,22 +28,22 @@ }, "moduleFolderNameInModeler": "datawidgets", "marketplace": { - "minimumMXVersion": "10.21.0.64362", + "minimumMXVersion": "10.24.0.73019", "appNumber": 116540, "appName": "Data Widgets" }, "testProject": { "githubUrl": "https://github.com/mendix/DataWidgets-module", - "branchName": "mts/10.21" + "branchName": "main" }, "scripts": { - "build:deps": "turbo run build --filter=data-widgets^...", + "build:deps": "turbo run build --filter=@mendix/data-widgets^...", "build:include-deps": "pnpm run build:deps && pnpm run build:module", "build:module": "ts-node --project scripts/tsconfig.json scripts/build.ts", "create-gh-release": "rui-create-gh-release", - "create-module-mpk": "turbo release:module --filter data-widgets", + "create-module-mpk": "turbo release:module --filter @mendix/data-widgets", "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", - "lint": "tsc --project scripts/tsconfig.json", + "lint": "tsc --project scripts/tsconfig.json && eslint . package.json", "publish-marketplace": "rui-publish-marketplace", "push-update": "ts-node --project scripts/tsconfig.json scripts/push-update.ts", "release:module": "ts-node --project scripts/tsconfig.json scripts/release.ts", @@ -63,6 +64,7 @@ }, "devDependencies": { "@mendix/automation-utils": "workspace:*", + "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/prettier-config-web-widgets": "workspace:*", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-terser": "^0.4.4", diff --git a/packages/modules/data-widgets/scripts/build.ts b/packages/modules/data-widgets/scripts/build.ts index cfaaac4ecd..688d3812fd 100755 --- a/packages/modules/data-widgets/scripts/build.ts +++ b/packages/modules/data-widgets/scripts/build.ts @@ -1,12 +1,12 @@ #!/usr/bin/env ts-node-script import { + copyActionsFiles, copyModuleLicense, copyThemesourceToProject, copyWidgetsToProject, runModuleSteps, - writeModuleVersion, - copyActionsFiles + writeModuleVersion } from "@mendix/automation-utils/steps"; import { bundleXLSX } from "./steps/bundle-xlsx"; @@ -15,7 +15,7 @@ async function main(): Promise { await runModuleSteps({ packagePath: process.cwd(), steps: [ - copyActionsFiles(["Export_To_Excel.js", "Reset_All_Filters.js", "Reset_Filter.js"]), + copyActionsFiles(["Export_To_Excel.js", "Reset_All_Filters.js", "Reset_Filter.js", "Clear_Selection.js"]), bundleXLSX, copyThemesourceToProject, writeModuleVersion, diff --git a/packages/modules/data-widgets/scripts/release.ts b/packages/modules/data-widgets/scripts/release.ts index f19845aaf6..d492e8ef22 100755 --- a/packages/modules/data-widgets/scripts/release.ts +++ b/packages/modules/data-widgets/scripts/release.ts @@ -1,8 +1,10 @@ #!/usr/bin/env ts-node-script import { + addREADMEOSSToMpk, addWidgetsToMpk, cloneTestProject, + copyActionsFiles, copyModuleLicense, copyThemesourceToProject, copyWidgetsToProject, @@ -10,8 +12,7 @@ import { moveModuleToDist, removeDist, runModuleSteps, - writeModuleVersion, - copyActionsFiles + writeModuleVersion } from "@mendix/automation-utils/steps"; import { bundleXLSX } from "./steps/bundle-xlsx"; @@ -24,12 +25,13 @@ async function main(): Promise { cloneTestProject, copyWidgetsToProject, copyThemesourceToProject, - copyActionsFiles(["Export_To_Excel.js", "Reset_All_Filters.js", "Reset_Filter.js"]), + copyActionsFiles(["Export_To_Excel.js", "Reset_All_Filters.js", "Reset_Filter.js", "Clear_Selection.js"]), bundleXLSX, writeModuleVersion, copyModuleLicense, createModuleMpk, addWidgetsToMpk, + addREADMEOSSToMpk, moveModuleToDist ] }); diff --git a/packages/modules/data-widgets/scripts/steps/bundle-xlsx.ts b/packages/modules/data-widgets/scripts/steps/bundle-xlsx.ts index 8fcb88492e..c5d17ef89b 100644 --- a/packages/modules/data-widgets/scripts/steps/bundle-xlsx.ts +++ b/packages/modules/data-widgets/scripts/steps/bundle-xlsx.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { ModuleStepParams, logStep, copyActionsFiles } from "@mendix/automation-utils/steps"; +import { copyActionsFiles, logStep, ModuleStepParams } from "@mendix/automation-utils/steps"; import * as rollup from "rollup"; import type { InputOptions, OutputOptions } from "rollup"; import resolve from "@rollup/plugin-node-resolve"; diff --git a/packages/modules/data-widgets/src/javascriptsource/datawidgets/actions/Clear_Selection.js b/packages/modules/data-widgets/src/javascriptsource/datawidgets/actions/Clear_Selection.js new file mode 100644 index 0000000000..848aa7cdab --- /dev/null +++ b/packages/modules/data-widgets/src/javascriptsource/datawidgets/actions/Clear_Selection.js @@ -0,0 +1,25 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +import "mx-global"; +import { Big } from "big.js"; + +// BEGIN EXTRA CODE +// END EXTRA CODE + +/** + * @param {string} targetName - The name of the widget for which selection should be cleared. + * @returns {Promise.} + */ +export async function Clear_Selection(targetName) { + // BEGIN USER CODE + const plugin = window["com.mendix.widgets.web.plugin.externalEvents"]; + if (plugin) { + plugin.emit(targetName, "selection.clear"); + } + // END USER CODE +} diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid-dropdown-filter.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid-dropdown-filter.scss index b77663d6cc..c25ddaead3 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid-dropdown-filter.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid-dropdown-filter.scss @@ -106,6 +106,12 @@ $root: ".widget-dropdown-filter"; } } + &-menu-item-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &-checkbox-slot { display: flex; margin-inline-end: var(--wdf-outer-spacing); @@ -138,7 +144,6 @@ $root: ".widget-dropdown-filter"; &-clear { @include btn-with-cross; align-items: center; - align-self: center; display: flex; flex-shrink: 0; justify-self: end; @@ -150,6 +155,11 @@ $root: ".widget-dropdown-filter"; &:has(+ #{$root}-toggle) { border-inline-end: 1px solid var(--gray, #787d87); } + + &:focus-visible { + outline-offset: -2px; + outline: var(--brand-primary, $brand-primary) solid 1px; + } } &-state-icon { @@ -262,9 +272,13 @@ $root: ".widget-dropdown-filter"; justify-content: center; line-height: 1.334; padding: var(--wdf-tag-padding); + margin: var(--spacing-smallest, 2px); &:focus-visible { outline: var(--brand-primary, #264ae5) auto 1px; } + &:focus { + background-color: var(--color-primary-light, $color-primary-light); + } } #{$root}-input { @@ -273,6 +287,14 @@ $root: ".widget-dropdown-filter"; width: initial; } + &:not(:focus-within):not([data-empty]) { + #{$root}-input { + opacity: 0; + flex-shrink: 1; + min-width: 1px; + } + } + #{$root}-clear { border-color: transparent; } diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index c40d78d6ed..75944994c5 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -335,7 +335,7 @@ $root: ".widget-datagrid"; color: $dg-pagination-caption-color; .paging-status { - padding: 0 8px 8px; + padding: 0 8px 0; } .pagination-button { @@ -410,7 +410,15 @@ $root: ".widget-datagrid"; position: relative; &-grid { - display: grid !important; + &.table { + display: grid !important; + min-width: fit-content; + margin-bottom: 0; + } + } + + &-content { + overflow-x: auto; } &-grid-head { @@ -541,6 +549,33 @@ $root: ".widget-datagrid"; z-index: 1; } +:where(#{$root}-paging-bottom) { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +:where(#{$root}-pb-start, #{$root}-pb-end, #{$root}-pb-middle) { + flex-grow: 1; + flex-basis: 33.33%; + min-height: 20px; +} + +:where(#{$root}-pb-start) { + margin-block: var(--spacing-medium); + padding-inline: var(--spacing-medium); +} + +#{$root}-clear-selection { + cursor: pointer; + background: transparent; + border: none; + text-decoration: underline; + color: var(--link-color); + padding: 0; + display: inline-block; +} + @keyframes skeleton-loading { 0% { background-position: right; diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss index 0fc17c23c4..c129caf07c 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss @@ -33,6 +33,21 @@ } } +.widget-gallery-divided-horizontal { + .widget-gallery-item { + position: relative; + &:not(:last-child)::after { + content: ""; + display: block; + position: absolute; + left: 0; + right: 0; + border-bottom: 1px solid var(--grid-border-color, $dg-grid-border-color); + margin-top: calc(var(--spacing-small, $dg-spacing-small) / 2); + } + } +} + // Hover styles .widget-gallery-hover { .widget-gallery-items { diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss index 743dd90017..8e1ac0b54d 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss @@ -3,8 +3,8 @@ Override styles of Gallery widget ========================================================================== */ -$gallery-screen-lg: 992px; -$gallery-screen-md: 768px; +$gallery-screen-lg: $screen-lg; +$gallery-screen-md: $screen-md; @mixin grid-items($number, $suffix) { @for $i from 1 through $number { @@ -88,3 +88,29 @@ $gallery-screen-md: 768px; .widget-gallery-item-button { width: inherit; } + +:where(.widget-gallery-footer-controls) { + display: flex; + flex-flow: row nowrap; +} + +:where(.widget-gallery-fc-start) { + margin-block: var(--spacing-medium); + padding-inline: var(--spacing-medium); +} + +:where(.widget-gallery-fc-start, .widget-gallery-fc-middle, .widget-gallery-fc-end) { + flex-grow: 1; + flex-basis: 33.33%; + min-height: 20px; +} + +.widget-gallery-clear-selection { + cursor: pointer; + background: transparent; + border: none; + text-decoration: underline; + color: var(--link-color); + padding: 0; + display: inline-block; +} diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_refresh-indicator.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_refresh-indicator.scss new file mode 100644 index 0000000000..8213ceab85 --- /dev/null +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_refresh-indicator.scss @@ -0,0 +1,83 @@ +.mx-refresh-container { + grid-column: 1 / -1; + padding: 0; + position: relative; + + &-padding { + padding: var(--spacing-small) 0; + } +} + +.mx-refresh-indicator { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--border-color-default, #ced0d3); + border: none; + border-radius: 2px; + color: var(--brand-primary, $dg-brand-primary); + height: 4px; + width: 100%; + position: absolute; + left: 0; + right: 0; + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-value { + background-color: currentColor; + transition: all 0.2s; + } + + &::-moz-progress-bar { + background-color: currentColor; + transition: all 0.2s; + } + + &::-ms-fill { + border: none; + background-color: currentColor; + transition: all 0.2s; + } + + &:indeterminate { + background-size: 200% 100%; + background-image: linear-gradient( + to right, + transparent 50%, + currentColor 50%, + currentColor 60%, + transparent 60%, + transparent 71.5%, + currentColor 71.5%, + currentColor 84%, + transparent 84% + ); + animation: progress-linear 3s infinite linear; + } + + &:indeterminate::-moz-progress-bar { + background-color: transparent; + } + + &:indeterminate::-ms-fill { + animation-name: none; + } + + @keyframes progress-linear { + 0% { + background-size: 200% 100%; + background-position: left -31.25% top 0%; + } + 50% { + background-size: 800% 100%; + background-position: left -49% top 0%; + } + 100% { + background-size: 400% 100%; + background-position: left -102% top 0%; + } + } +} diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json b/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json index 565cb5d4f4..c58b1bdd3c 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json @@ -59,6 +59,10 @@ { "name": "Horizontal", "class": "widget-gallery-bordered-horizontal" + }, + { + "name": "Horizontal divider", + "class": "widget-gallery-divided-horizontal" } ] }, diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/main.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/main.scss index 19fbcb8846..c1d5304319 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/main.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/main.scss @@ -6,6 +6,7 @@ @import "drop-down-sort"; @import "gallery"; @import "gallery-design-properties"; +@import "refresh-indicator"; @import "three-state-checkbox"; @import "tree-node"; @import "tree-node-design-properties"; diff --git a/packages/modules/file-uploader/CHANGELOG.md b/packages/modules/file-uploader/CHANGELOG.md index 8283e36120..8e10cd0720 100644 --- a/packages/modules/file-uploader/CHANGELOG.md +++ b/packages/modules/file-uploader/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [2.3.0] FileUploader - 2025-08-15 + +### [2.3.0] FileUploader + +#### Fixed + +- We improved the file extension validation to allow special characters like dashes and plus signs (e.g., '.tar-gz', '.c++'). + +- We clarified error messages for invalid file extensions to better explain the expected format. + +- We fixed an issue where file uploader can still add more files when refreshed eventhough the number of maximum uploaded files has been reached. + +#### Changed + +- We change the max file configuration to set maximum number of uploaded files through expression. + +## [2.2.2] FileUploader - 2025-07-01 + +### [2.2.2] FileUploader + +#### Fixed + +- We fixed image thumbnail issue that was failed to reload on refresh page. + ## [2.2.1] FileUploader - 2025-05-28 ### [2.2.1] FileUploader diff --git a/packages/modules/file-uploader/LICENSE b/packages/modules/file-uploader/LICENSE index 51dfbf50dc..8c705ebe13 100644 --- a/packages/modules/file-uploader/LICENSE +++ b/packages/modules/file-uploader/LICENSE @@ -1,4 +1,4 @@ -Apache License + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +186,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Mendix Technology BV + Copyright 2022 Mendix Technology B.V. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -198,4 +198,4 @@ Apache License distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/packages/modules/file-uploader/package.json b/packages/modules/file-uploader/package.json index f1e7ad8302..6a44fe4a2a 100644 --- a/packages/modules/file-uploader/package.json +++ b/packages/modules/file-uploader/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/file-uploader", "moduleName": "File Uploader module", - "version": "2.2.1", + "version": "2.3.0", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", "private": true, diff --git a/packages/modules/file-uploader/scripts/release.ts b/packages/modules/file-uploader/scripts/release.ts index d8ff81f729..6472347c37 100755 --- a/packages/modules/file-uploader/scripts/release.ts +++ b/packages/modules/file-uploader/scripts/release.ts @@ -1,6 +1,7 @@ #!/usr/bin/env ts-node-script import { + addREADMEOSSToMpk, addWidgetsToMpk, cloneTestProject, copyModuleLicense, @@ -23,6 +24,7 @@ async function main(): Promise { copyWidgetsToProject, createModuleMpk, addWidgetsToMpk, + addREADMEOSSToMpk, moveModuleToDist ] }); diff --git a/packages/modules/google-tag/scripts/release.ts b/packages/modules/google-tag/scripts/release.ts index 99a1db7ec4..1a82a431e9 100755 --- a/packages/modules/google-tag/scripts/release.ts +++ b/packages/modules/google-tag/scripts/release.ts @@ -1,6 +1,7 @@ #!/usr/bin/env ts-node-script import { + addREADMEOSSToMpk, addWidgetsToMpk, cloneTestProject, copyActionsFiles, @@ -23,6 +24,7 @@ async function main(): Promise { writeVersionAndLicenseToJSActions, createModuleMpk, addWidgetsToMpk, + addREADMEOSSToMpk, moveModuleToDist ] }); diff --git a/packages/modules/web-actions/package.json b/packages/modules/web-actions/package.json index 6d6f5763cc..48157a6f1d 100644 --- a/packages/modules/web-actions/package.json +++ b/packages/modules/web-actions/package.json @@ -35,7 +35,7 @@ "verify": "rui-verify-package-format" }, "devDependencies": { - "@eslint/js": "^9.24.0", + "@eslint/js": "^9.32.0", "@mendix/automation-utils": "workspace:*", "@mendix/prettier-config-web-widgets": "workspace:^", "globals": "^16.0.0" diff --git a/packages/modules/web-actions/scripts/release.ts b/packages/modules/web-actions/scripts/release.ts index da45ccaeca..add9c8eaeb 100755 --- a/packages/modules/web-actions/scripts/release.ts +++ b/packages/modules/web-actions/scripts/release.ts @@ -1,4 +1,5 @@ import { + addREADMEOSSToMpk, cloneTestProject, copyActionsFiles, createModuleMpk, @@ -29,6 +30,7 @@ async function main(): Promise { copyThemesourceToProject, writeVersionAndLicenseToJSActions, createModuleMpk, + addREADMEOSSToMpk, moveModuleToDist ] }); diff --git a/packages/modules/web-actions/src/javascriptsource/webactions/actions/TakePicture.js b/packages/modules/web-actions/src/javascriptsource/webactions/actions/TakePicture.js index f97e54f858..683f382c23 100644 --- a/packages/modules/web-actions/src/javascriptsource/webactions/actions/TakePicture.js +++ b/packages/modules/web-actions/src/javascriptsource/webactions/actions/TakePicture.js @@ -222,7 +222,7 @@ export async function TakePicture(picture, showConfirmationScreen, pictureQualit cleanupConfirmationElements(); onResumeFirstScreen(); }); - // eslint-disable-next-line no-inner-declarations + function cleanupConfirmationElements() { document.body.removeChild(confirmationWrapper); videoCanvas.remove(); diff --git a/packages/pluggableWidgets/accessibility-helper-web/package.json b/packages/pluggableWidgets/accessibility-helper-web/package.json index 6de7a467d5..6d5e27bbde 100644 --- a/packages/pluggableWidgets/accessibility-helper-web/package.json +++ b/packages/pluggableWidgets/accessibility-helper-web/package.json @@ -40,6 +40,9 @@ "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, + "dependencies": { + "@mendix/widget-plugin-component-kit": "workspace:*" + }, "devDependencies": { "@mendix/automation-utils": "workspace:*", "@mendix/eslint-config-web-widgets": "workspace:*", diff --git a/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.editorConfig.ts b/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.editorConfig.ts index 2e92deb9a5..73aedcbf60 100644 --- a/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.editorConfig.ts +++ b/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.editorConfig.ts @@ -1,12 +1,12 @@ import { - StructurePreviewProps, DropZoneProps, RowLayoutProps, - TextProps, - structurePreviewPalette + structurePreviewPalette, + StructurePreviewProps, + TextProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; -import { hidePropertyIn, Properties, Problem } from "@mendix/pluggable-widgets-tools"; -import { AttributesListPreviewType, AccessibilityHelperPreviewProps } from "../typings/AccessibilityHelperProps"; +import { hidePropertyIn, Problem, Properties } from "@mendix/pluggable-widgets-tools"; +import { AccessibilityHelperPreviewProps, AttributesListPreviewType } from "../typings/AccessibilityHelperProps"; const PROHIBITED_ATTRIBUTES = ["class", "style", "widgetid", "data-mendix-id"]; diff --git a/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.tsx b/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.tsx index f680ab0d28..e7bdc161e1 100644 --- a/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.tsx +++ b/packages/pluggableWidgets/accessibility-helper-web/src/AccessibilityHelper.tsx @@ -1,6 +1,6 @@ -import { createElement, MutableRefObject, ReactElement, useCallback, useEffect, useRef, useMemo } from "react"; +import { createElement, MutableRefObject, ReactElement, useCallback, useEffect, useMemo, useRef } from "react"; -import { ValueStatus, DynamicValue } from "mendix"; +import { DynamicValue, ValueStatus } from "mendix"; import { AccessibilityHelperContainerProps } from "../typings/AccessibilityHelperProps"; diff --git a/packages/pluggableWidgets/accessibility-helper-web/src/package.xml b/packages/pluggableWidgets/accessibility-helper-web/src/package.xml index 0251833d5b..12734ddb8e 100644 --- a/packages/pluggableWidgets/accessibility-helper-web/src/package.xml +++ b/packages/pluggableWidgets/accessibility-helper-web/src/package.xml @@ -5,7 +5,7 @@ - + diff --git a/packages/pluggableWidgets/accordion-web/package.json b/packages/pluggableWidgets/accordion-web/package.json index eaf5cab10d..33c16d2fae 100644 --- a/packages/pluggableWidgets/accordion-web/package.json +++ b/packages/pluggableWidgets/accordion-web/package.json @@ -38,12 +38,12 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "jest --projects jest.config.js", + "test": "pluggable-widgets-tools test:unit:web:enzyme-free", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2" + "classnames": "^2.5.1" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", diff --git a/packages/pluggableWidgets/accordion-web/src/Accordion.editorConfig.ts b/packages/pluggableWidgets/accordion-web/src/Accordion.editorConfig.ts index b276c537c4..0ef8bb4543 100644 --- a/packages/pluggableWidgets/accordion-web/src/Accordion.editorConfig.ts +++ b/packages/pluggableWidgets/accordion-web/src/Accordion.editorConfig.ts @@ -1,8 +1,8 @@ import { ContainerProps, RowLayoutProps, - StructurePreviewProps, - structurePreviewPalette + structurePreviewPalette, + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { hideNestedPropertiesIn, diff --git a/packages/pluggableWidgets/accordion-web/src/components/AccordionGroup.tsx b/packages/pluggableWidgets/accordion-web/src/components/AccordionGroup.tsx index a425f75a93..9a5c292b3d 100644 --- a/packages/pluggableWidgets/accordion-web/src/components/AccordionGroup.tsx +++ b/packages/pluggableWidgets/accordion-web/src/components/AccordionGroup.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import { createElement, KeyboardEvent, ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { LoadContentEnum } from "typings/AccordionProps"; -import { useDebouncedResizeObserver, CallResizeObserver } from "../utils/resizeObserver"; +import { CallResizeObserver, useDebouncedResizeObserver } from "../utils/resizeObserver"; import "../ui/accordion-main.scss"; export const enum Target { diff --git a/packages/pluggableWidgets/accordion-web/src/package.xml b/packages/pluggableWidgets/accordion-web/src/package.xml index aa26022678..fbc80ffee0 100644 --- a/packages/pluggableWidgets/accordion-web/src/package.xml +++ b/packages/pluggableWidgets/accordion-web/src/package.xml @@ -5,7 +5,7 @@ - + diff --git a/packages/pluggableWidgets/area-chart-web/CHANGELOG.md b/packages/pluggableWidgets/area-chart-web/CHANGELOG.md index 31c64e4597..51cc6f0a39 100644 --- a/packages/pluggableWidgets/area-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/area-chart-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [6.2.1] - 2025-07-15 + +### Changed + +- We updated shared charts dependency. + ## [6.2.0] - 2025-06-03 ### Fixed diff --git a/packages/pluggableWidgets/area-chart-web/jest.config.js b/packages/pluggableWidgets/area-chart-web/jest.config.js index 88999d5568..d6f16cea46 100644 --- a/packages/pluggableWidgets/area-chart-web/jest.config.js +++ b/packages/pluggableWidgets/area-chart-web/jest.config.js @@ -1,3 +1,4 @@ module.exports = { - ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js") + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js"), + testEnvironment: "@happy-dom/jest-environment" }; diff --git a/packages/pluggableWidgets/area-chart-web/package.json b/packages/pluggableWidgets/area-chart-web/package.json index cfe7b1c2f7..2da7c572be 100644 --- a/packages/pluggableWidgets/area-chart-web/package.json +++ b/packages/pluggableWidgets/area-chart-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/area-chart-web", "widgetName": "AreaChart", - "version": "6.2.0", + "version": "6.2.1", "description": "An area chart displays a solid color between the traces of a graph.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", @@ -39,10 +39,12 @@ }, "dependencies": { "@mendix/shared-charts": "workspace:*", - "classnames": "^2.3.2", + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1", "plotly.js-dist-min": "^3.0.0" }, "devDependencies": { + "@happy-dom/jest-environment": "^18.0.1", "@mendix/automation-utils": "workspace:*", "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/pluggable-widgets-tools": "*", diff --git a/packages/pluggableWidgets/area-chart-web/src/AreaChart.editorConfig.ts b/packages/pluggableWidgets/area-chart-web/src/AreaChart.editorConfig.ts index 5c84771670..f9ed253997 100644 --- a/packages/pluggableWidgets/area-chart-web/src/AreaChart.editorConfig.ts +++ b/packages/pluggableWidgets/area-chart-web/src/AreaChart.editorConfig.ts @@ -1,18 +1,18 @@ import { - Problem, - Properties, hideNestedPropertiesIn, hidePropertiesIn, hidePropertyIn, + Problem, + Properties, transformGroupsIntoTabs } from "@mendix/pluggable-widgets-tools"; import { checkSlot, withPlaygroundSlot } from "@mendix/shared-charts/preview"; import { ContainerProps, + datasource, ImageProps, - StructurePreviewProps, rowLayout, - datasource + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { AreaChartPreviewProps } from "../typings/AreaChartProps"; diff --git a/packages/pluggableWidgets/area-chart-web/src/AreaChart.tsx b/packages/pluggableWidgets/area-chart-web/src/AreaChart.tsx index 3b8858f647..175129b576 100644 --- a/packages/pluggableWidgets/area-chart-web/src/AreaChart.tsx +++ b/packages/pluggableWidgets/area-chart-web/src/AreaChart.tsx @@ -1,8 +1,8 @@ import { ChartWidget, ChartWidgetProps, - SeriesMapper, containerPropsEqual, + SeriesMapper, usePlotChartDataSeries } from "@mendix/shared-charts/main"; import "@mendix/shared-charts/ui/Chart.scss"; diff --git a/packages/pluggableWidgets/area-chart-web/src/__tests__/AreaChart.spec.tsx b/packages/pluggableWidgets/area-chart-web/src/__tests__/AreaChart.spec.tsx index 7805817b35..396c4d4595 100644 --- a/packages/pluggableWidgets/area-chart-web/src/__tests__/AreaChart.spec.tsx +++ b/packages/pluggableWidgets/area-chart-web/src/__tests__/AreaChart.spec.tsx @@ -14,8 +14,6 @@ import { createElement } from "react"; import { SeriesType } from "../../typings/AreaChartProps"; import { AreaChart } from "../AreaChart"; -jest.mock("react-plotly.js", () => jest.fn(() => null)); - describe("The AreaChart widget", () => { function renderAreaChart(configs: Array>): RenderResult { return render( diff --git a/packages/pluggableWidgets/area-chart-web/src/package.xml b/packages/pluggableWidgets/area-chart-web/src/package.xml index 0929537270..93fd6aaca1 100644 --- a/packages/pluggableWidgets/area-chart-web/src/package.xml +++ b/packages/pluggableWidgets/area-chart-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/badge-button-web/package.json b/packages/pluggableWidgets/badge-button-web/package.json index f173a39710..b11e3055c7 100644 --- a/packages/pluggableWidgets/badge-button-web/package.json +++ b/packages/pluggableWidgets/badge-button-web/package.json @@ -37,12 +37,13 @@ "publish-marketplace": "rui-publish-marketplace", "release": "cross-env MPKOUTPUT=BadgeButton.mpk pluggable-widgets-tools release:web", "start": "cross-env MPKOUTPUT=BadgeButton.mpk pluggable-widgets-tools start:server", - "test": "jest --projects jest.config.js", + "test": "pluggable-widgets-tools test:unit:web:enzyme-free", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2" + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", diff --git a/packages/pluggableWidgets/badge-button-web/src/BadgeButton.editorConfig.ts b/packages/pluggableWidgets/badge-button-web/src/BadgeButton.editorConfig.ts index 6e953baa35..06d4a5d168 100644 --- a/packages/pluggableWidgets/badge-button-web/src/BadgeButton.editorConfig.ts +++ b/packages/pluggableWidgets/badge-button-web/src/BadgeButton.editorConfig.ts @@ -1,7 +1,7 @@ import { BadgeButtonPreviewProps } from "../typings/BadgeButtonProps"; import { - StructurePreviewProps, - structurePreviewPalette + structurePreviewPalette, + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; export function getPreview(values: BadgeButtonPreviewProps, isDarkMode: boolean): StructurePreviewProps { diff --git a/packages/pluggableWidgets/badge-button-web/src/BadgeButton.tsx b/packages/pluggableWidgets/badge-button-web/src/BadgeButton.tsx index 907e74bb31..02c50d0aa6 100644 --- a/packages/pluggableWidgets/badge-button-web/src/BadgeButton.tsx +++ b/packages/pluggableWidgets/badge-button-web/src/BadgeButton.tsx @@ -1,4 +1,4 @@ -import { createElement, useCallback, ReactNode } from "react"; +import { createElement, ReactNode, useCallback } from "react"; import { BadgeButton as BadgeButtonComponent } from "./components/BadgeButton"; import { BadgeButtonContainerProps } from "../typings/BadgeButtonProps"; diff --git a/packages/pluggableWidgets/badge-web/package.json b/packages/pluggableWidgets/badge-web/package.json index 3132be6eae..69bc99e56f 100644 --- a/packages/pluggableWidgets/badge-web/package.json +++ b/packages/pluggableWidgets/badge-web/package.json @@ -37,12 +37,13 @@ "publish-marketplace": "rui-publish-marketplace", "release": "cross-env MPKOUTPUT=Badge.mpk pluggable-widgets-tools release:web", "start": "cross-env MPKOUTPUT=Badge.mpk pluggable-widgets-tools start:server", - "test": "jest --projects jest.config.js", + "test": "pluggable-widgets-tools test:unit:web:enzyme-free", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2" + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", diff --git a/packages/pluggableWidgets/badge-web/src/Badge.editorConfig.ts b/packages/pluggableWidgets/badge-web/src/Badge.editorConfig.ts index 124c5dace9..8b2023b54b 100644 --- a/packages/pluggableWidgets/badge-web/src/Badge.editorConfig.ts +++ b/packages/pluggableWidgets/badge-web/src/Badge.editorConfig.ts @@ -1,6 +1,6 @@ import { - StructurePreviewProps, - structurePreviewPalette + structurePreviewPalette, + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { BadgePreviewProps } from "../typings/BadgeProps"; diff --git a/packages/pluggableWidgets/badge-web/src/Badge.tsx b/packages/pluggableWidgets/badge-web/src/Badge.tsx index a99dc2c117..60bb5e21fc 100644 --- a/packages/pluggableWidgets/badge-web/src/Badge.tsx +++ b/packages/pluggableWidgets/badge-web/src/Badge.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, createElement, KeyboardEvent } from "react"; +import { createElement, KeyboardEvent, ReactNode, useCallback } from "react"; import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { BadgeContainerProps } from "../typings/BadgeProps"; diff --git a/packages/pluggableWidgets/badge-web/src/package.xml b/packages/pluggableWidgets/badge-web/src/package.xml index 4dba84a896..ba3b624e00 100644 --- a/packages/pluggableWidgets/badge-web/src/package.xml +++ b/packages/pluggableWidgets/badge-web/src/package.xml @@ -5,7 +5,7 @@ - + diff --git a/packages/pluggableWidgets/bar-chart-web/CHANGELOG.md b/packages/pluggableWidgets/bar-chart-web/CHANGELOG.md index 4c2e228426..482886e036 100644 --- a/packages/pluggableWidgets/bar-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/bar-chart-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [6.2.1] - 2025-07-15 + +### Changed + +- We updated shared charts dependency. + ## [6.2.0] - 2025-06-03 ### Fixed diff --git a/packages/pluggableWidgets/bar-chart-web/jest.config.js b/packages/pluggableWidgets/bar-chart-web/jest.config.js index 88999d5568..d6f16cea46 100644 --- a/packages/pluggableWidgets/bar-chart-web/jest.config.js +++ b/packages/pluggableWidgets/bar-chart-web/jest.config.js @@ -1,3 +1,4 @@ module.exports = { - ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js") + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js"), + testEnvironment: "@happy-dom/jest-environment" }; diff --git a/packages/pluggableWidgets/bar-chart-web/package.json b/packages/pluggableWidgets/bar-chart-web/package.json index 396c217930..23145d7915 100644 --- a/packages/pluggableWidgets/bar-chart-web/package.json +++ b/packages/pluggableWidgets/bar-chart-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/bar-chart-web", "widgetName": "BarChart", - "version": "6.2.0", + "version": "6.2.1", "description": "Shows difference between the data points for one or more categories.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", @@ -39,10 +39,12 @@ }, "dependencies": { "@mendix/shared-charts": "workspace:*", - "classnames": "^2.3.2", + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1", "plotly.js-dist-min": "^3.0.0" }, "devDependencies": { + "@happy-dom/jest-environment": "^18.0.1", "@mendix/automation-utils": "workspace:*", "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/pluggable-widgets-tools": "*", diff --git a/packages/pluggableWidgets/bar-chart-web/src/BarChart.editorConfig.ts b/packages/pluggableWidgets/bar-chart-web/src/BarChart.editorConfig.ts index 100f3082f1..51f0dcf529 100644 --- a/packages/pluggableWidgets/bar-chart-web/src/BarChart.editorConfig.ts +++ b/packages/pluggableWidgets/bar-chart-web/src/BarChart.editorConfig.ts @@ -1,10 +1,10 @@ import { checkSlot, withPlaygroundSlot } from "@mendix/shared-charts/preview"; import { BarChartPreviewProps, BarmodeEnum } from "../typings/BarChartProps"; import { - StructurePreviewProps, ContainerProps, + datasource, ImageProps, - datasource + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { hideNestedPropertiesIn, diff --git a/packages/pluggableWidgets/bar-chart-web/src/BarChart.tsx b/packages/pluggableWidgets/bar-chart-web/src/BarChart.tsx index 1628cb14a7..1c23283452 100644 --- a/packages/pluggableWidgets/bar-chart-web/src/BarChart.tsx +++ b/packages/pluggableWidgets/bar-chart-web/src/BarChart.tsx @@ -1,7 +1,7 @@ import { ChartWidget, ChartWidgetProps, containerPropsEqual, usePlotChartDataSeries } from "@mendix/shared-charts/main"; import "@mendix/shared-charts/ui/Chart.scss"; import classNames from "classnames"; -import { ReactElement, createElement, memo, useCallback, useMemo } from "react"; +import { createElement, memo, ReactElement, useCallback, useMemo } from "react"; import { BarChartContainerProps } from "../typings/BarChartProps"; diff --git a/packages/pluggableWidgets/bar-chart-web/src/package.xml b/packages/pluggableWidgets/bar-chart-web/src/package.xml index 687257e3a7..684b73439c 100644 --- a/packages/pluggableWidgets/bar-chart-web/src/package.xml +++ b/packages/pluggableWidgets/bar-chart-web/src/package.xml @@ -1,11 +1,11 @@ - + - + diff --git a/packages/pluggableWidgets/barcode-scanner-web/package.json b/packages/pluggableWidgets/barcode-scanner-web/package.json index 1c6fc47039..d8b4e32a3d 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/package.json +++ b/packages/pluggableWidgets/barcode-scanner-web/package.json @@ -37,13 +37,13 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "pluggable-widgets-tools test:unit:web", + "test": "pluggable-widgets-tools test:unit:web:enzyme-free", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, "dependencies": { "@zxing/library": "~0.21.3", - "classnames": "^2.3.2" + "classnames": "^2.5.1" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/components/BarcodeScanner.tsx b/packages/pluggableWidgets/barcode-scanner-web/src/components/BarcodeScanner.tsx index b02ea8714d..ba9e0da958 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/components/BarcodeScanner.tsx +++ b/packages/pluggableWidgets/barcode-scanner-web/src/components/BarcodeScanner.tsx @@ -1,4 +1,4 @@ -import { createElement, ReactElement, ReactNode, useCallback, SyntheticEvent, useRef, RefObject } from "react"; +import { createElement, ReactElement, ReactNode, RefObject, SyntheticEvent, useCallback, useRef } from "react"; import classNames from "classnames"; import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; import { Dimensions, getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/components/__tests__/BarcodeScanner.spec.tsx b/packages/pluggableWidgets/barcode-scanner-web/src/components/__tests__/BarcodeScanner.spec.tsx index 5040ac7b8f..a5079bc1c8 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/components/__tests__/BarcodeScanner.spec.tsx +++ b/packages/pluggableWidgets/barcode-scanner-web/src/components/__tests__/BarcodeScanner.spec.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { act, render, waitFor, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { createElement } from "react"; import { Dimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; import { NotFoundException } from "@zxing/library/cjs"; diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/components/utils.tsx b/packages/pluggableWidgets/barcode-scanner-web/src/components/utils.tsx index d4ce87b2ab..b37ba68039 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/components/utils.tsx +++ b/packages/pluggableWidgets/barcode-scanner-web/src/components/utils.tsx @@ -1,10 +1,10 @@ import { + BarcodeFormat, BinaryBitmap, BrowserMultiFormatReader, + DecodeHintType, HTMLCanvasElementLuminanceSource, HybridBinarizer, - BarcodeFormat, - DecodeHintType, Result } from "@zxing/library"; import { BarcodeFormatsType } from "typings/BarcodeScannerProps"; diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useCustomErrorMessage.ts b/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useCustomErrorMessage.ts index 72c4e6d2f6..bca3f45e9e 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useCustomErrorMessage.ts +++ b/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useCustomErrorMessage.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useCallback, useState } from "react"; type ErrorCb = (error: E) => void; diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useReader.ts b/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useReader.ts index 6d079dfa89..1cd4eff3fc 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useReader.ts +++ b/packages/pluggableWidgets/barcode-scanner-web/src/hooks/useReader.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, RefObject } from "react"; +import { RefObject, useEffect, useRef } from "react"; import { BrowserMultiFormatReader, NotFoundException, Result } from "@zxing/library"; import { useEventCallback } from "@mendix/widget-plugin-hooks/useEventCallback"; import { BarcodeFormatsType } from "../../typings/BarcodeScannerProps"; diff --git a/packages/pluggableWidgets/barcode-scanner-web/src/package.xml b/packages/pluggableWidgets/barcode-scanner-web/src/package.xml index 9cf0833372..5cbb21ea61 100644 --- a/packages/pluggableWidgets/barcode-scanner-web/src/package.xml +++ b/packages/pluggableWidgets/barcode-scanner-web/src/package.xml @@ -1,11 +1,11 @@ - + - + diff --git a/packages/pluggableWidgets/bubble-chart-web/CHANGELOG.md b/packages/pluggableWidgets/bubble-chart-web/CHANGELOG.md index e3b816fac4..a9cdfe0c0a 100644 --- a/packages/pluggableWidgets/bubble-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/bubble-chart-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [6.2.1] - 2025-07-15 + +### Changed + +- We updated shared charts dependency. + ## [6.2.0] - 2025-06-03 ### Fixed diff --git a/packages/pluggableWidgets/bubble-chart-web/jest.config.js b/packages/pluggableWidgets/bubble-chart-web/jest.config.js index 88999d5568..d6f16cea46 100644 --- a/packages/pluggableWidgets/bubble-chart-web/jest.config.js +++ b/packages/pluggableWidgets/bubble-chart-web/jest.config.js @@ -1,3 +1,4 @@ module.exports = { - ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js") + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js"), + testEnvironment: "@happy-dom/jest-environment" }; diff --git a/packages/pluggableWidgets/bubble-chart-web/package.json b/packages/pluggableWidgets/bubble-chart-web/package.json index 055276a8d6..e51399304d 100644 --- a/packages/pluggableWidgets/bubble-chart-web/package.json +++ b/packages/pluggableWidgets/bubble-chart-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/bubble-chart-web", "widgetName": "BubbleChart", - "version": "6.2.0", + "version": "6.2.1", "description": "Shows data in a bubble format graph.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", @@ -39,10 +39,12 @@ }, "dependencies": { "@mendix/shared-charts": "workspace:*", - "classnames": "^2.3.2", + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1", "plotly.js-dist-min": "^3.0.0" }, "devDependencies": { + "@happy-dom/jest-environment": "^18.0.1", "@mendix/automation-utils": "workspace:*", "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/pluggable-widgets-tools": "*", diff --git a/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.editorConfig.ts b/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.editorConfig.ts index e0ec3d6480..fa3ece0434 100644 --- a/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.editorConfig.ts +++ b/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.editorConfig.ts @@ -1,8 +1,8 @@ import { ContainerProps, + datasource, ImageProps, - StructurePreviewProps, - datasource + StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { checkSlot, withPlaygroundSlot } from "@mendix/shared-charts/preview"; import { diff --git a/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.tsx b/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.tsx index f9377ae833..953b892425 100644 --- a/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.tsx +++ b/packages/pluggableWidgets/bubble-chart-web/src/BubbleChart.tsx @@ -3,7 +3,7 @@ import "@mendix/shared-charts/ui/Chart.scss"; import { defaultEqual, flatEqual } from "@mendix/widget-plugin-platform/utils/flatEqual"; import Big from "big.js"; import classNames from "classnames"; -import { ReactElement, createElement, memo, useCallback } from "react"; +import { createElement, memo, ReactElement, useCallback } from "react"; import { BubbleChartContainerProps, LinesType } from "../typings/BubbleChartProps"; import { calculateSizeRef } from "./utils"; diff --git a/packages/pluggableWidgets/bubble-chart-web/src/package.xml b/packages/pluggableWidgets/bubble-chart-web/src/package.xml index 118cdd6a80..b02123520b 100644 --- a/packages/pluggableWidgets/bubble-chart-web/src/package.xml +++ b/packages/pluggableWidgets/bubble-chart-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/calendar-web/CHANGELOG.md b/packages/pluggableWidgets/calendar-web/CHANGELOG.md index 2fa9989a52..90f317da03 100644 --- a/packages/pluggableWidgets/calendar-web/CHANGELOG.md +++ b/packages/pluggableWidgets/calendar-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -### Added +## [2.0.0] - 2025-08-12 -- initial version of calendar widget. +### Breaking changes + +- Initial version of Calendar pluggable widget. + +- Upgrading from any v1.x to v2.0.0 preview requires re-configuring the widget in Studio Pro. The property panel has been reorganised (e.g. View settings, Custom work-week options) and missing/renamed properties will be reset to their defaults. After installing v2.0.0 open each Calendar instance, review the settings and re-select the desired values. diff --git a/packages/pluggableWidgets/calendar-web/jest.config.js b/packages/pluggableWidgets/calendar-web/jest.config.js index 4b623cc8e0..f820408aa7 100644 --- a/packages/pluggableWidgets/calendar-web/jest.config.js +++ b/packages/pluggableWidgets/calendar-web/jest.config.js @@ -1,5 +1,4 @@ -const { join } = require("path"); -const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config"); +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js"); module.exports = { ...base, diff --git a/packages/pluggableWidgets/calendar-web/package.json b/packages/pluggableWidgets/calendar-web/package.json index 872d6298c2..50dc75af0f 100644 --- a/packages/pluggableWidgets/calendar-web/package.json +++ b/packages/pluggableWidgets/calendar-web/package.json @@ -20,8 +20,8 @@ }, "packagePath": "com.mendix.widget.web", "marketplace": { - "minimumMXVersion": "9.24.0", - "appNumber": 224259, + "minimumMXVersion": "10.22.0", + "appNumber": 107954, "appName": "Calendar", "reactReady": true }, @@ -30,25 +30,25 @@ "branchName": "calendar-web" }, "scripts": { - "build": "pluggable-widgets-tools build:web", + "build": "cross-env MPKOUTPUT=Calendar.mpk pluggable-widgets-tools build:web", "create-gh-release": "rui-create-gh-release", "create-translation": "rui-create-translation", - "dev": "pluggable-widgets-tools start:web", + "dev": "cross-env MPKOUTPUT=Calendar.mpk pluggable-widgets-tools start:web", "e2e": "echo \"Skipping this e2e test\"", "e2edev": "run-e2e dev --with-preps", "format": "pluggable-widgets-tools format", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", "publish-marketplace": "rui-publish-marketplace", - "release": "pluggable-widgets-tools release:web", - "start": "pluggable-widgets-tools start:server", - "test": "jest --projects jest.config.js", + "release": "cross-env MPKOUTPUT=Calendar.mpk pluggable-widgets-tools release:web", + "start": "cross-env MPKOUTPUT=Calendar.mpk pluggable-widgets-tools start:server", + "test": "cross-env TZ=UTC jest --projects jest.config.js", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2", + "classnames": "^2.5.1", "date-fns": "^4.1.0", - "react-big-calendar": "^1.17.1" + "react-big-calendar": "^1.19.4" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", @@ -60,7 +60,7 @@ "@mendix/widget-plugin-hooks": "workspace:*", "@mendix/widget-plugin-platform": "workspace:*", "@mendix/widget-plugin-test-utils": "workspace:*", - "@types/react-big-calendar": "^1.16.1", + "@types/react-big-calendar": "^1.16.2", "cross-env": "^7.0.3" } } diff --git a/packages/pluggableWidgets/calendar-web/rollup.config.mjs b/packages/pluggableWidgets/calendar-web/rollup.config.mjs new file mode 100644 index 0000000000..34a4c65272 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/rollup.config.mjs @@ -0,0 +1,31 @@ +import commonjs from "@rollup/plugin-commonjs"; + +export default args => { + const result = args.configDefaultConfig; + return result.map(config => { + config.output.inlineDynamicImports = true; + if (config.output.format !== "es") { + return config; + } + return { + ...config, + plugins: [ + ...config.plugins.map(plugin => { + if (plugin && plugin.name === "commonjs") { + // replace common js plugin that transforms + // external requires to imports + // this is needed in order to work with modern client + return commonjs({ + extensions: [".js", ".jsx", ".tsx", ".ts"], + transformMixedEsModules: true, + requireReturnsDefault: "auto", + esmExternals: true + }); + } + + return plugin; + }) + ] + }; + }); +}; diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.editorConfig.ts b/packages/pluggableWidgets/calendar-web/src/Calendar.editorConfig.ts index 678a346d4a..411cb34a80 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.editorConfig.ts +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.editorConfig.ts @@ -1,12 +1,34 @@ import { - StructurePreviewProps, container, rowLayout, structurePreviewPalette, + StructurePreviewProps, text } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; -import { Properties, hidePropertyIn, hidePropertiesIn } from "@mendix/pluggable-widgets-tools"; +import { hidePropertiesIn, hidePropertyIn, Properties } from "@mendix/pluggable-widgets-tools"; import { CalendarPreviewProps } from "../typings/CalendarProps"; +import IconSVGDark from "./assets/StructureCalendarDark.svg"; +import IconSVG from "./assets/StructureCalendarLight.svg"; + +const CUSTOM_VIEW_CONFIG: Array = [ + "customViewShowDay", + "customViewShowWeek", + "customViewShowMonth", + "customViewShowAgenda", + "customViewShowCustomWeek", + "customViewCaption", + "defaultViewCustom" +]; + +const CUSTOM_VIEW_DAYS_CONFIG: Array = [ + "customViewShowMonday", + "customViewShowTuesday", + "customViewShowWednesday", + "customViewShowThursday", + "customViewShowFriday", + "customViewShowSaturday", + "customViewShowSunday" +]; export function getProperties(values: CalendarPreviewProps, defaultProperties: Properties): Properties { if (values.heightUnit === "percentageOfWidth") { @@ -29,6 +51,17 @@ export function getProperties(values: CalendarPreviewProps, defaultProperties: P hidePropertiesIn(defaultProperties, values, ["maxHeight", "overflowY"]); } + // Hide custom week range properties when the view is set to 'standard' + if (values.view === "standard") { + hidePropertiesIn(defaultProperties, values, [...CUSTOM_VIEW_CONFIG, ...CUSTOM_VIEW_DAYS_CONFIG]); + } else { + hidePropertyIn(defaultProperties, values, "defaultViewStandard"); + + if (values.customViewShowCustomWeek === false) { + hidePropertiesIn(defaultProperties, values, ["customViewCaption", ...CUSTOM_VIEW_DAYS_CONFIG]); + } + } + // Show/hide title properties based on selection if (values.titleType === "attribute") { hidePropertyIn(defaultProperties, values, "titleExpression"); @@ -41,10 +74,25 @@ export function getProperties(values: CalendarPreviewProps, defaultProperties: P export function getPreview(_values: CalendarPreviewProps, isDarkMode: boolean): StructurePreviewProps { const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; + const readOnly = _values.readOnly; - return rowLayout({ columnSize: "grow", borders: true, backgroundColor: palette.background.containerFill })( - container()(), - rowLayout({ grow: 2, padding: 8 })(text({ fontColor: palette.text.primary, grow: 10 })("calendar")), - container()() + return container({ + backgroundColor: readOnly ? palette.background.containerDisabled : palette.background.topbarData, + borders: true + })( + rowLayout({ + columnSize: "grow", + padding: 6 + })( + container({ padding: 4, grow: 0 })({ + type: "Image", + document: decodeURIComponent((isDarkMode ? IconSVGDark : IconSVG).replace("data:image/svg+xml,", "")), + width: 16, + height: 16 + }), + container({ + padding: 4 + })(text({ fontColor: palette.text.primary })("Calendar")) + ) ); } diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.editorPreview.tsx b/packages/pluggableWidgets/calendar-web/src/Calendar.editorPreview.tsx index 3d380ee926..7239f1f1c0 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.editorPreview.tsx +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.editorPreview.tsx @@ -1,18 +1,19 @@ import classnames from "classnames"; -import * as dateFns from "date-fns"; -import { ReactElement, createElement } from "react"; -import { Calendar, dateFnsLocalizer } from "react-big-calendar"; +import { createElement, ReactElement } from "react"; +import { Calendar, dateFnsLocalizer, EventPropGetter } from "react-big-calendar"; import { CalendarPreviewProps } from "../typings/CalendarProps"; import { CustomToolbar } from "./components/Toolbar"; -import "react-big-calendar/lib/css/react-big-calendar.css"; import { constructWrapperStyle, WrapperStyleProps } from "./utils/style-utils"; -import { eventPropGetter } from "./utils/calendar-utils"; +import { eventPropGetter, format, getDay, parse, startOfWeek } from "./utils/calendar-utils"; + +import "react-big-calendar/lib/css/react-big-calendar.css"; +import "./ui/Calendar.scss"; const localizer = dateFnsLocalizer({ - format: dateFns.format, - parse: dateFns.parse, - startOfWeek: dateFns.startOfWeek, - getDay: dateFns.getDay, + format, + parse, + startOfWeek, + getDay, locales: {} }); @@ -71,15 +72,19 @@ export function preview(props: CalendarPreviewProps): ReactElement { const { class: className } = props; const wrapperStyle = constructWrapperStyle(props as WrapperStyleProps); + // Cast eventPropGetter to satisfy preview Calendar generic + const previewEventPropGetter = eventPropGetter as unknown as EventPropGetter<(typeof events)[0]>; + return (
); diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.tsx b/packages/pluggableWidgets/calendar-web/src/Calendar.tsx index 3983e9d64e..fb6f9ae18e 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.tsx +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.tsx @@ -1,18 +1,29 @@ -import classnames from "classnames"; -import { ReactElement, createElement } from "react"; -import { DnDCalendar, extractCalendarProps } from "./utils/calendar-utils"; +import { createElement, ReactElement, useMemo } from "react"; +import classNames from "classnames"; import { CalendarContainerProps } from "../typings/CalendarProps"; +import { CalendarPropsBuilder } from "./helpers/CalendarPropsBuilder"; +import { DnDCalendar } from "./utils/calendar-utils"; import { constructWrapperStyle } from "./utils/style-utils"; import "./ui/Calendar.scss"; +import { useCalendarEvents } from "./helpers/useCalendarEvents"; export default function MxCalendar(props: CalendarContainerProps): ReactElement { - const { class: className } = props; - const wrapperStyle = constructWrapperStyle(props); - const calendarProps = extractCalendarProps(props); + // useMemo with empty dependency array is used + // because style and calendar controller needs to be created only once + // and not on every re-render + // eslint-disable-next-line react-hooks/exhaustive-deps + const wrapperStyle = useMemo(() => constructWrapperStyle(props), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const calendarController = useMemo(() => new CalendarPropsBuilder(props), []); + const calendarProps = useMemo(() => { + calendarController.updateProps(props); + return calendarController.build(); + }, [props, calendarController]); + const calendarEvents = useCalendarEvents(props); return ( -
- +
+
); } diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.xml b/packages/pluggableWidgets/calendar-web/src/Calendar.xml index 87461d416e..cbd5b84c7b 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.xml +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.xml @@ -2,6 +2,7 @@ Calendar Calendar + Display Display https://docs.mendix.com/appstore/widgets/calendar @@ -55,14 +56,28 @@ Color attribute - Attribute containing a valid html color eg: red #FF0000 rgb(250,10,20) rgba(10,10,10, 0.5) + Attribute containing a valid HTML color eg: red #FF0000 rgb(250,10,20) rgba(10,10,10, 0.5) + + Start date attribute + The start date that should be shown in the view + + + + + + + + Editable + + + View Standard has day, week and month @@ -71,67 +86,127 @@ Custom - - Editable - + + Initial selected view + The default view showed when the calendar is loaded - Default - Never + Day + Week + Month - - Enable create - - - + Initial selected view - Work week and agenda are only available in custom views + The default view showed when the calendar is loaded Day Week Month - (Work week) - (Agenda) + Custom + Agenda - - Start date attribute - The start date that should be shown in the view - - - + + Show event date range + Show the start and end date of the event + + + + Time format + Default time format is "hh:mm a" + + + Day start hour + The hour at which the day view starts (0–23) + + + Day end hour + The hour at which the day view ends (1–24) + + + Show all events + Auto-adjust calendar height to display all events without "more" links + + + + + + + Day + Show day view in the toolbar + + + Week + Show week view in the toolbar + + + Custom Work Week + Show custom week view in the toolbar + + + + Custom view caption + Label used for the custom work-week button and title. Defaults to "Custom". + + Custom + + + + Month + Show month view in the toolbar + + + Agenda + Show agenda view in the toolbar + + + + + Monday + Show Monday in the custom week view + + + Tuesday + Show Tuesday in the custom week view + + + Wednesday + Show Wednesday in the custom week view + + + Thursday + Show Thursday in the custom week view + + + Friday + Show Friday in the custom week view + + + Saturday + Show Saturday in the custom week view + + + Sunday + Show Sunday in the custom week view - - Event data attribute - The attribute to store received raw data - - - - - - On click action + + On edit - - - - - - - On create action + On create The create event is triggered when a time slot is selected, and the 'Enable create' property is set to 'true' - + - - On change action + + On drag/drop/resize The change event is triggered on moving/dragging an item or changing the start or end time of by resizing an item @@ -140,7 +215,7 @@ - + On view range change Triggered when the calendar view range (start/end) changes diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx index b602843a21..03d15e9381 100644 --- a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx +++ b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx @@ -1,28 +1,80 @@ import { createElement } from "react"; import { render } from "@testing-library/react"; -import { ListValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { dynamic, ListValueBuilder } from "@mendix/widget-plugin-test-utils"; -import Calendar from "../Calendar"; +import MxCalendar from "../Calendar"; import { CalendarContainerProps } from "../../typings/CalendarProps"; -const defaultProps: CalendarContainerProps = { + +// Mock react-big-calendar to avoid View.title issues +jest.mock("react-big-calendar", () => { + const originalModule = jest.requireActual("react-big-calendar"); + return { + ...originalModule, + Calendar: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + dateFnsLocalizer: () => ({ + format: jest.fn(), + parse: jest.fn(), + startOfWeek: jest.fn(), + getDay: jest.fn() + }), + Views: { + MONTH: "month", + WEEK: "week", + WORK_WEEK: "work_week", + DAY: "day", + AGENDA: "agenda" + } + }; +}); + +jest.mock("react-big-calendar/lib/addons/dragAndDrop", () => { + return jest.fn((Component: any) => Component); +}); + +const customViewProps: CalendarContainerProps = { name: "calendar-test", class: "calendar-class", tabIndex: 0, databaseDataSource: new ListValueBuilder().withItems([]).build(), titleType: "attribute", - view: "standard", - defaultView: "month", - editable: "default", - enableCreate: true, + view: "custom", + defaultViewStandard: "month", + defaultViewCustom: "work_week", + editable: dynamic(true), + showEventDate: dynamic(true), widthUnit: "percentage", width: 100, heightUnit: "pixels", height: 400, + minHour: 0, + maxHour: 24, minHeightUnit: "pixels", minHeight: 400, maxHeightUnit: "none", maxHeight: 400, - overflowY: "auto" + overflowY: "auto", + customViewShowSunday: false, + customViewShowMonday: true, + customViewShowTuesday: true, + customViewShowWednesday: true, + customViewShowThursday: true, + customViewShowFriday: true, + customViewShowSaturday: false, + showAllEvents: true, + customViewShowDay: true, + customViewShowWeek: true, + customViewShowCustomWeek: false, + customViewShowMonth: true, + customViewShowAgenda: false +}; + +const standardViewProps: CalendarContainerProps = { + ...customViewProps, + view: "standard" }; beforeAll(() => { @@ -36,13 +88,20 @@ afterAll(() => { describe("Calendar", () => { it("renders correctly with basic props", () => { - const calendar = render(); - expect(calendar).toMatchSnapshot(); + const calendar = render(); + expect(calendar.container.firstChild).toMatchSnapshot(); }); it("renders with correct class name", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".widget-calendar")).toBeTruthy(); expect(container.querySelector(".calendar-class")).toBeTruthy(); }); + + it("does not render custom view button in standard view", () => { + const { container } = render(); + expect(container).toBeTruthy(); + // Since we're mocking the calendar, we can't test for specific text content + // but we can verify the component renders without errors + }); }); diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap b/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap index d1357fa96a..c99cf60c98 100644 --- a/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap +++ b/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap @@ -1,1560 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Calendar renders correctly with basic props 1`] = ` -{ - "asFragment": [Function], - "baseElement": -
-
-
-
-
- - - -
-
- - April 2025 - -
-
- - - -
-
-
-
-
- - Sun - -
-
- - Mon - -
-
- - Tue - -
-
- - Wed - -
-
- - Thu - -
-
- - Fri - -
-
- - Sat - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
- , - "container":
-
-
-
-
- - - -
-
- - April 2025 - -
-
- - - -
-
-
-
-
- - Sun - -
-
- - Mon - -
-
- - Tue - -
-
- - Wed - -
-
- - Thu - -
-
- - Fri - -
-
- - Sat - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} +
+
+
`; diff --git a/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarDark.svg b/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarDark.svg new file mode 100644 index 0000000000..93dcef3e6f --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarDark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarLight.svg b/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarLight.svg new file mode 100644 index 0000000000..a08bfb17f8 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/src/assets/StructureCalendarLight.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/pluggableWidgets/calendar-web/src/components/Toolbar.tsx b/packages/pluggableWidgets/calendar-web/src/components/Toolbar.tsx index 6ba2640e53..75835e9c19 100644 --- a/packages/pluggableWidgets/calendar-web/src/components/Toolbar.tsx +++ b/packages/pluggableWidgets/calendar-web/src/components/Toolbar.tsx @@ -1,36 +1,41 @@ import { Button } from "@mendix/widget-plugin-component-kit/Button"; import { IconInternal } from "@mendix/widget-plugin-component-kit/IconInternal"; import classNames from "classnames"; -import { createElement, ReactElement } from "react"; -import { Navigate, ToolbarProps } from "react-big-calendar"; +import { createElement, ReactElement, useCallback } from "react"; +import { Navigate, ToolbarProps, View } from "react-big-calendar"; import "react-big-calendar/lib/css/react-big-calendar.css"; export function CustomToolbar({ label, localizer, onNavigate, onView, view, views }: ToolbarProps): ReactElement { + const handlePrev = useCallback(() => onNavigate(Navigate.PREVIOUS), [onNavigate]); + const handleToday = useCallback(() => onNavigate(Navigate.TODAY), [onNavigate]); + const handleNext = useCallback(() => onNavigate(Navigate.NEXT), [onNavigate]); + const handleView = useCallback((name: View) => onView(name), [onView]); + return (
-
- - -
-
+
{label}
-
+
{Array.isArray(views) && views.map(name => { return (
- {show && optionsComponent} + {show && ( + + {optionsComponent} + + )}
); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx index 44988ced5f..ff32371397 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx @@ -1,4 +1,4 @@ -import { createElement, ReactElement, PropsWithChildren } from "react"; +import { createElement, PropsWithChildren, ReactElement } from "react"; import { PseudoModal } from "./PseudoModal"; import { ExportAlert, ExportAlertProps } from "./ExportAlert"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx index cdfb7c6644..c5b39a1adf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { ReactElement, createElement, JSX } from "react"; +import { createElement, JSX, ReactElement } from "react"; import { PaginationEnum } from "../../typings/DatagridProps"; import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index dc3bac60c7..64c1ca93b8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -8,7 +8,7 @@ interface Props { className?: string; children?: React.ReactNode; loadingType: LoadingTypeEnum; - isLoading: boolean; + isFirstLoad: boolean; isFetchingNextBatch?: boolean; columnsHidable: boolean; columnsSize: number; @@ -20,7 +20,7 @@ export function GridBody(props: Props): ReactElement { const { children } = props; const content = (): React.ReactElement => { - if (props.isLoading) { + if (props.isFirstLoad) { return 0 ? props.rowsSize : props.pageSize} />; } return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/PseudoModal.tsx b/packages/pluggableWidgets/datagrid-web/src/components/PseudoModal.tsx index fea0591bc4..cfed36b83b 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/PseudoModal.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/PseudoModal.tsx @@ -1,4 +1,4 @@ -import { createElement, ReactElement, PropsWithChildren } from "react"; +import { createElement, PropsWithChildren, ReactElement } from "react"; export function PseudoModal(props: PropsWithChildren): ReactElement { return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx index 78cf469d52..e0d4bfe25b 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; import { ObjectItem } from "mendix"; -import { ReactElement, createElement } from "react"; +import { createElement, ReactElement } from "react"; import { CellComponent, EventsController } from "../typings/CellComponent"; import { GridColumn } from "../typings/GridColumn"; import { SelectorCell } from "./SelectorCell"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/StickySentinel.tsx b/packages/pluggableWidgets/datagrid-web/src/components/StickySentinel.tsx deleted file mode 100644 index cde33fdd50..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/StickySentinel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import classNames from "classnames"; -import { createElement, ReactElement, useState, useEffect, useRef } from "react"; - -/** - * StickySentinel - A small hidden element that uses "IntersectionObserver" - * to detect the "scrolled" state of the grid. By toggling the "container-stuck" class - * on this element, we can force "position: sticky" for column headers. - */ -export function StickySentinel(): ReactElement { - const sentinelRef = useRef(null); - const [ratio, setRatio] = useState(1); - - useEffect(() => { - const target = sentinelRef.current; - - if (target === null) { - return; - } - - return createObserver(target, setRatio); - }, []); - - return ( -
- ); -} - -function createObserver(target: Element, onIntersectionChange: (ratio: number) => void): () => void { - const options = { threshold: [0, 1] }; - - const observer = new IntersectionObserver(([entry]) => { - if (entry.intersectionRatio === 0 || entry.intersectionRatio === 1) { - onIntersectionChange(entry.intersectionRatio); - } - }, options); - - observer.observe(target); - - return () => observer.unobserve(target); -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index c3d1beeba2..032fd9f74d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -1,30 +1,30 @@ +import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator"; import { Pagination } from "@mendix/widget-plugin-grid/components/Pagination"; -import { SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import classNames from "classnames"; import { ListActionValue, ObjectItem } from "mendix"; -import { CSSProperties, ReactElement, ReactNode, createElement, Fragment } from "react"; +import { observer } from "mobx-react-lite"; +import { createElement, CSSProperties, Fragment, ReactElement, ReactNode } from "react"; import { - PagingPositionEnum, + LoadingTypeEnum, PaginationEnum, - ShowPagingButtonsEnum, - LoadingTypeEnum + PagingPositionEnum, + ShowPagingButtonsEnum } from "../../typings/DatagridProps"; -import { WidgetPropsProvider } from "../helpers/useWidgetProps"; +import { SelectActionHelper } from "../helpers/SelectActionHelper"; +import { useDatagridRootScope } from "../helpers/root-context"; import { CellComponent, EventsController } from "../typings/CellComponent"; import { ColumnId, GridColumn } from "../typings/GridColumn"; +import { ExportWidget } from "./ExportWidget"; import { Grid } from "./Grid"; import { GridBody } from "./GridBody"; +import { GridHeader } from "./GridHeader"; +import { RowsRenderer } from "./RowsRenderer"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; import { WidgetTopBar } from "./WidgetTopBar"; -import { ExportWidget } from "./ExportWidget"; -import { SelectActionHelper } from "../helpers/SelectActionHelper"; -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { observer } from "mobx-react-lite"; -import { RowsRenderer } from "./RowsRenderer"; -import { GridHeader } from "./GridHeader"; export interface WidgetProps { CellComponent: CellComponent; @@ -55,20 +55,15 @@ export interface WidgetProps string; - gridInteractive: boolean; setPage?: (computePage: (prevPage: number) => number) => void; styles?: CSSProperties; rowAction?: ListActionValue; - selectionStatus: SelectionStatus; showSelectAllToggle?: boolean; - exportDialogLabel?: string; - cancelExportLabel?: string; - selectRowLabel?: string; - selectAllRowsLabel?: string; - isLoading: boolean; + isFirstLoad: boolean; isFetchingNextBatch: boolean; loadingType: LoadingTypeEnum; columnsLoading: boolean; + showRefreshIndicator: boolean; // Helpers cellEventsController: EventsController; @@ -85,32 +80,31 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; + const { basicData } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; return ( - - -
- {exporting && ( - - )} - - + +
+ {exporting && ( + + )} + ); }); @@ -131,11 +125,14 @@ const Main = observer((props: WidgetProps): ReactElemen paging, pagingPosition, preview, + showRefreshIndicator, selectActionHelper, setPage, visibleColumns } = props; + const { basicData } = useDatagridRootScope(); + const showHeader = !!headerContent; const showTopBar = paging && (pagingPosition === "top" || pagingPosition === "both"); @@ -189,8 +186,9 @@ const Main = observer((props: WidgetProps): ReactElemen isLoading={props.columnsLoading} preview={props.preview} /> + {showRefreshIndicator ? : null} (props: WidgetProps): ReactElemen > - {(pagingPosition === "bottom" || pagingPosition === "both") && pagination} - {hasMoreItems && paginationType === "loadMore" && ( - - )} +
+
+ +
+ {hasMoreItems && paginationType === "loadMore" && ( +
+ +
+ )} +
+ {(pagingPosition === "bottom" || pagingPosition === "both") && pagination} +
+
); } + +const SelectionCounter = observer(function SelectionCounter() { + const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); + + return ( + + {selectionCountStore.displayCount} |  + + + ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx index 840cf55d2d..12db0017a1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx @@ -1,35 +1,27 @@ import { getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; -import { HeaderFiltersStore } from "@mendix/widget-plugin-filtering/stores/generic/HeaderFiltersStore"; import { getGlobalSelectionContext, SelectionHelper, useCreateSelectionContextValue } from "@mendix/widget-plugin-grid/selection"; import { createElement, memo, ReactElement, ReactNode } from "react"; +import { RootGridStore } from "../helpers/state/RootGridStore"; interface WidgetHeaderContextProps { children?: ReactNode; - filtersStore: HeaderFiltersStore; selectionHelper?: SelectionHelper; + rootStore: RootGridStore; } const SelectionContext = getGlobalSelectionContext(); const FilterContext = getGlobalFilterContextObject(); -function FilterAPIProvider(props: { filtersStore: HeaderFiltersStore; children?: ReactNode }): ReactElement { - return {props.children}; -} - -function SelectionStatusProvider(props: { selectionHelper?: SelectionHelper; children?: ReactNode }): ReactElement { - const value = useCreateSelectionContextValue(props.selectionHelper); - return {props.children}; -} - function HeaderContainer(props: WidgetHeaderContextProps): ReactElement { + const selectionContext = useCreateSelectionContextValue(props.selectionHelper); return ( - - {props.children} - + + {props.children} + ); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx index 9341f2d143..b4ade333f8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { ReactElement, createElement, useRef, useMemo } from "react"; +import { createElement, ReactElement, useMemo, useRef } from "react"; import { SelectionMethod } from "../helpers/SelectActionHelper"; type P = JSX.IntrinsicElements["div"]; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx index fe449c54af..ac7117597d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx @@ -1,4 +1,5 @@ -import { render } from "enzyme"; +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; import { createElement } from "react"; import { ColumnResizer } from "../ColumnResizer"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx index 6301000f63..4eca3ba61e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx @@ -1,6 +1,8 @@ -import { render, shallow } from "enzyme"; +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createElement } from "react"; -import { GridColumn } from "../../typings/GridColumn"; +import { ColumnId, GridColumn } from "../../typings/GridColumn"; import { ColumnResizer } from "../ColumnResizer"; import { Header, HeaderProps } from "../Header"; @@ -8,81 +10,77 @@ describe("Header", () => { it("renders the structure correctly", () => { const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when sortable", () => { const props = mockHeaderProps(); props.column.canSort = true; props.sortable = true; - const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when resizable", () => { const props = mockHeaderProps(); props.column.canResize = true; props.resizable = true; - const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when draggable", () => { const props = mockHeaderProps(); props.column.canDrag = true; props.draggable = true; - const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when filterable with no custom filter", () => { const props = mockHeaderProps(); props.filterable = true; - const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when filterable with custom filter", () => { const props = mockHeaderProps(); - const filterWidget = ( + props.filterable = true; + props.filterWidget = (
); - props.filterable = true; - - const component = render(
); + const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); - it("calls setSortBy store function with correct parameters when sortable", () => { + it("calls setSortBy store function with correct parameters when sortable", async () => { + const user = userEvent.setup(); const mockedFunction = jest.fn(); - const column = { - columnId: "0", + const props = mockHeaderProps(); + props.sortable = true; + props.column = { + ...props.column, + columnId: "0" as ColumnId, columnNumber: 0, header: "My sortable column", canSort: true, sortDir: undefined, toggleSort: mockedFunction } as any; + const component = render(
); + const button = component.getByRole("button"); - const component = shallow(
); - - const clickableRegion = component.find(".column-header"); - - expect(clickableRegion).toHaveLength(1); - - clickableRegion.simulate("click"); + expect(button).toBeInTheDocument(); + await user.click(button); expect(mockedFunction).toHaveBeenCalled(); }); @@ -92,7 +90,7 @@ describe("Header", () => { const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when is hidden and preview", () => { @@ -103,7 +101,7 @@ describe("Header", () => { const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly when value is empty", () => { @@ -112,7 +110,7 @@ describe("Header", () => { const component = render(
); - expect(component).toMatchSnapshot(); + expect(component.asFragment()).toMatchSnapshot(); }); }); @@ -120,11 +118,27 @@ function mockHeaderProps(): HeaderProps { return { gridId: "dg1", column: { - columnId: "dg1-column0", + columnId: "dg1-column0" as ColumnId, columnIndex: 0, header: "Test", sortDir: undefined, - toggleSort: () => undefined + toggleSort: () => undefined, + setHeaderElementRef: jest.fn(), + alignment: "left", + canDrag: false, + columnClass: () => undefined, + initiallyHidden: false, + renderCellContent: () => createElement("div"), + isAvailable: true, + wrapText: false, + canHide: false, + isHidden: false, + toggleHidden: () => undefined, + canSort: false, + canResize: false, + size: undefined, + setSize: () => undefined, + getCssWidth: () => "100px" } as GridColumn, draggable: false, dropTarget: undefined, diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index 15edc5fdba..32198b930c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -1,21 +1,24 @@ -import "@testing-library/jest-dom"; import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionMultiValueBuilder, list, listWidget, objectItems } from "@mendix/widget-plugin-test-utils"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { list, listWidget, objectItems, SelectionMultiValueBuilder } from "@mendix/widget-plugin-test-utils"; +import "@testing-library/jest-dom"; import { cleanup, getAllByRole, getByRole, queryByRole, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ListValue, ObjectItem, SelectionMultiValue } from "mendix"; -import { ReactElement, createElement } from "react"; +import { createElement, ReactElement } from "react"; +import { ItemSelectionMethodEnum } from "typings/DatagridProps"; import { CellEventsController, useCellEventsController } from "../../features/row-interaction/CellEventsController"; import { CheckboxEventsController, useCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController"; import { SelectActionHelper, useSelectActionHelper } from "../../helpers/SelectActionHelper"; +import { DatagridContext, DatagridRootScope } from "../../helpers/root-context"; +import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridColumn } from "../../typings/GridColumn"; import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; import { Widget, WidgetProps } from "../Widget"; -import { ItemSelectionMethodEnum } from "typings/DatagridProps"; // you can also pass the mock implementation // to jest.fn as an argument @@ -29,45 +32,92 @@ window.IntersectionObserver = jest.fn(() => ({ takeRecords: jest.fn() })); +function withCtx( + widgetProps: WidgetProps, + contextOverrides: Partial = {} +): React.ReactElement { + const defaultBasicData = { + gridInteractive: false, + selectionStatus: "none" as const, + setSelectionHelper: jest.fn(), + exportDialogLabel: undefined, + cancelExportLabel: undefined, + selectRowLabel: undefined, + selectAllRowsLabel: undefined + }; + + const defaultSelectionCountStore = { + selectedCount: 0, + displayCount: "", + fmtSingular: "%d row selected", + fmtPlural: "%d rows selected" + }; + + const mockContext = { + basicData: defaultBasicData as unknown as GridBasicData, + selectionHelper: undefined, + selectActionHelper: widgetProps.selectActionHelper, + cellEventsController: widgetProps.cellEventsController, + checkboxEventsController: widgetProps.checkboxEventsController, + focusController: widgetProps.focusController, + selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, + ...contextOverrides + }; + + return ( + + + + ); +} + +// Helper function to render Widget with root context +function renderWithRootContext( + widgetProps: WidgetProps, + contextOverrides: Partial = {} +): ReturnType { + return render(withCtx(widgetProps, contextOverrides)); +} + describe("Table", () => { it("renders the structure correctly", () => { - const component = render(); + const component = renderWithRootContext(mockWidgetProps()); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with sorting", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsSortable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with resizing", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsResizable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with dragging", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsDraggable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with filtering", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsFilterable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with hiding", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsHidable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with paging", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), paging: true }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -78,15 +128,16 @@ describe("Table", () => { props.columnsFilterable = true; props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with empty placeholder", () => { - const component = render( - renderWrapper(
)} /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + emptyPlaceholderRenderer: renderWrapper => renderWrapper(
) + }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -103,13 +154,13 @@ describe("Table", () => { props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with dynamic row class", () => { - const component = render( "myclass"} />); + const component = renderWithRootContext({ ...mockWidgetProps(), rowClass: () => "myclass" }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -121,38 +172,34 @@ describe("Table", () => { props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with header wrapper", () => { - const component = render( - ( -
- {header} -
- )} - /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + headerWrapperRenderer: (index, header) => ( +
+ {header} +
+ ) + }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with header filters and a11y", () => { - const component = render( - - -
- } - headerTitle="filter title" - /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + headerContent: ( +
+ +
+ ), + headerTitle: "filter title" + }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -163,13 +210,14 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); - props.gridInteractive = true; props.paging = true; props.data = objectItems(3); }); it("render method class", () => { - const { container } = render(); + const { container } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-checkbox"); }); @@ -177,7 +225,9 @@ describe("Table", () => { it("render an extra column and add class to each selected row", () => { props.selectActionHelper.isSelected = () => true; - const { asFragment } = render(); + const { asFragment } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(asFragment()).toMatchSnapshot(); }); @@ -190,24 +240,24 @@ describe("Table", () => { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getChecked = () => screen.getAllByRole("checkbox").filter(elt => elt.checked); - const { rerender } = render(); + const { rerender } = render(withCtx(props)); expect(getChecked()).toHaveLength(0); selection = [a, b, c]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(3); selection = [c]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(1); selection = [d, e]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(2); selection = [f, e, d, a]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(4); }); @@ -229,7 +279,9 @@ describe("Table", () => { jest.fn() ); - render(); + renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); const checkbox1 = screen.getAllByRole("checkbox")[0]; const checkbox3 = screen.getAllByRole("checkbox")[2]; @@ -257,7 +309,7 @@ describe("Table", () => { props.data = objectItems(5); props.paging = true; props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); - render(); + renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; expect(queryByRole(colheader, "checkbox")).toBeNull(); @@ -271,7 +323,9 @@ describe("Table", () => { props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); const renderWithStatus = (status: MultiSelectionStatus): ReturnType => { - return render(); + return renderWithRootContext(props, { + basicData: { selectionStatus: status } as unknown as GridBasicData + }); }; renderWithStatus("none"); @@ -290,7 +344,7 @@ describe("Table", () => { const props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); - render(); + renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; expect(queryByRole(colheader, "checkbox")).toBeNull(); @@ -300,9 +354,10 @@ describe("Table", () => { const props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); props.selectActionHelper.onSelectAll = jest.fn(); - props.selectionStatus = "none"; - render(); + renderWithRootContext(props, { + basicData: { selectionStatus: "none" } as unknown as GridBasicData + }); const checkbox = screen.getAllByRole("checkbox")[0]; @@ -320,13 +375,14 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); - props.gridInteractive = true; props.paging = true; props.data = objectItems(3); }); it("render method class", () => { - const { container } = render(); + const { container } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-click"); }); @@ -334,7 +390,9 @@ describe("Table", () => { it("add class to each selected cell", () => { props.selectActionHelper.isSelected = () => true; - const { asFragment } = render(); + const { asFragment } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(asFragment()).toMatchSnapshot(); }); @@ -361,7 +419,9 @@ describe("Table", () => { jest.fn() ); - render(); + renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); const rows = screen.getAllByRole("row").slice(1); expect(rows).toHaveLength(3); @@ -413,7 +473,7 @@ describe("Table", () => { }: WidgetProps & { selectionMethod: ItemSelectionMethodEnum; }): ReactElement { - const helper = useSelectionHelper(selection, ds, undefined); + const helper = useSelectionHelper(selection, ds, undefined, "always clear"); const selectHelper = useSelectActionHelper( { itemSelection: selection, @@ -432,14 +492,28 @@ describe("Table", () => { const checkboxEventsController = useCheckboxEventsController(selectHelper, props.focusController); + const contextValue = { + basicData: { + gridInteractive: true, + selectionStatus: helper?.type === "Multi" ? helper.selectionStatus : "unknown" + } as unknown as GridBasicData, + selectionHelper: helper, + selectActionHelper: selectHelper, + cellEventsController, + checkboxEventsController, + focusController: props.focusController, + selectionCountStore: {} as unknown as SelectionCountStore + }; + return ( - + + + ); } @@ -618,7 +692,7 @@ describe("Table", () => { const user = userEvent.setup(); - render(); + renderWithRootContext({ ...props, data: items }); const [input] = screen.getAllByRole("textbox"); await user.click(input); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap index 2e2f935f3b..35e61c875c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap @@ -1,12 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Column Resizer renders the structure correctly 1`] = ` -
@@ -1156,7 +1288,18 @@ exports[`Table renders the structure correctly with resizing 1`] = `
@@ -1592,93 +1757,104 @@ exports[`Table with selection method rowClick add class to each selected cell 1` class="widget-datagrid-footer table-footer" >
- - + - - Currently showing 11 to 20 - - - + - - - - - + Currently showing 11 to 20 + + + +
+
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx index f39daa8ef0..e9569e2897 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx @@ -1,8 +1,8 @@ import { createElement, Fragment, ReactElement } from "react"; +import { useDatagridRootScope } from "../../helpers/root-context"; import { CellElement } from "../CellElement"; -import { SkeletonLoader } from "./SkeletonLoader"; -import { useWidgetProps } from "../../helpers/useWidgetProps"; import { SelectorCell } from "../SelectorCell"; +import { SkeletonLoader } from "./SkeletonLoader"; type RowSkeletonLoaderProps = { columnsHidable: boolean; @@ -17,7 +17,7 @@ export function RowSkeletonLoader({ pageSize, useBorderTop = true }: RowSkeletonLoaderProps): ReactElement { - const { selectActionHelper } = useWidgetProps(); + const { selectActionHelper } = useDatagridRootScope(); return ( {Array.from({ length: pageSize }).map((_, i) => { diff --git a/packages/pluggableWidgets/datagrid-web/src/consistency-check.ts b/packages/pluggableWidgets/datagrid-web/src/consistency-check.ts index bd6a615c83..de02c76a80 100644 --- a/packages/pluggableWidgets/datagrid-web/src/consistency-check.ts +++ b/packages/pluggableWidgets/datagrid-web/src/consistency-check.ts @@ -4,13 +4,7 @@ import { ColumnsPreviewType, DatagridPreviewProps } from "../typings/DatagridPro export function check(values: DatagridPreviewProps): Problem[] { const errors: Problem[] = []; - const columnChecks = [ - checkAssociationSettings, - checkFilteringSettings, - checkDisplaySettings, - checkSortingSettings, - checkHidableSettings - ]; + const columnChecks = [checkDisplaySettings, checkSortingSettings, checkHidableSettings]; values.columns.forEach((column: ColumnsPreviewType, index) => { for (const check of columnChecks) { @@ -28,51 +22,6 @@ export function check(values: DatagridPreviewProps): Problem[] { const columnPropPath = (prop: string, index: number): string => `columns/${index + 1}/${prop}`; -const checkAssociationSettings = ( - values: DatagridPreviewProps, - column: ColumnsPreviewType, - index: number -): Problem | undefined => { - if (!values.columnsFilterable) { - return; - } - - if (!column.filterAssociation) { - return; - } - - if (column.filterCaptionType === "expression" && !column.filterAssociationOptionLabel) { - return { - property: columnPropPath("filterAssociationOptionLabel", index), - message: `A caption is required when using associations. Please set 'Option caption' property for column (${column.header})` - }; - } - - if (column.filterCaptionType === "attribute" && !column.filterAssociationOptionLabelAttr) { - return { - property: columnPropPath("filterAssociationOptionLabelAttr", index), - message: `A caption is required when using associations. Please set 'Option caption' property for column (${column.header})` - }; - } -}; - -const checkFilteringSettings = ( - values: DatagridPreviewProps, - column: ColumnsPreviewType, - index: number -): Problem | undefined => { - if (!values.columnsFilterable) { - return; - } - - if (!column.attribute && !column.filterAssociation) { - return { - property: columnPropPath("attribute", index), - message: `An attribute or reference is required when filtering is enabled. Please select 'Attribute' or 'Reference' property for column (${column.header})` - }; - } -}; - const checkDisplaySettings = ( _values: DatagridPreviewProps, column: ColumnsPreviewType, diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts new file mode 100644 index 0000000000..a24dae8d7f --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts @@ -0,0 +1,53 @@ +import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; +import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; +import { FilterCondition } from "mendix/filters"; +import { reaction } from "mobx"; +import { SortInstruction } from "../typings/sorting"; + +interface ObservableFilterStore { + filter: FilterCondition | undefined; +} + +interface ObservableSortStore { + sortInstructions: SortInstruction[] | undefined; +} + +type DatasourceParamsControllerSpec = { + query: QueryController; + filterHost: ObservableFilterStore; + sortHost: ObservableSortStore; +}; + +export class DatasourceParamsController implements ReactiveController { + private query: QueryController; + private filterHost: ObservableFilterStore; + private sortHost: ObservableSortStore; + + constructor(host: ReactiveControllerHost, spec: DatasourceParamsControllerSpec) { + host.addController(this); + this.filterHost = spec.filterHost; + this.sortHost = spec.sortHost; + this.query = spec.query; + } + + setup(): () => void { + const [add, disposeAll] = disposeBatch(); + add( + reaction( + () => this.sortHost.sortInstructions, + sortOrder => this.query.setSortOrder(sortOrder), + { fireImmediately: true } + ) + ); + add( + reaction( + () => this.filterHost.filter, + filter => this.query.setFilter(filter), + { fireImmediately: true } + ) + ); + + return disposeAll; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts index 47f456be63..02492e1f31 100644 --- a/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts @@ -1,21 +1,28 @@ import { computed, makeObservable } from "mobx"; type DerivedLoaderControllerSpec = { + showSilentRefresh: boolean; + refreshIndicator: boolean; exp: { exporting: boolean }; cols: { loaded: boolean }; - query: { isFetchingNextBatch: boolean; isLoading: boolean; isRefreshing: boolean }; + query: { + isFetchingNextBatch: boolean; + isFirstLoad: boolean; + isRefreshing: boolean; + isSilentRefresh: boolean; + }; }; export class DerivedLoaderController { constructor(private spec: DerivedLoaderControllerSpec) { makeObservable(this, { - isLoading: computed, + isFirstLoad: computed, isFetchingNextBatch: computed, isRefreshing: computed }); } - get isLoading(): boolean { + get isFirstLoad(): boolean { const { cols, exp, query } = this.spec; if (!cols.loaded) { return true; @@ -25,7 +32,7 @@ export class DerivedLoaderController { return false; } - return query.isLoading; + return query.isFirstLoad; } get isFetchingNextBatch(): boolean { @@ -33,6 +40,20 @@ export class DerivedLoaderController { } get isRefreshing(): boolean { - return this.spec.query.isRefreshing; + const { isSilentRefresh, isRefreshing } = this.spec.query; + + if (this.spec.showSilentRefresh) { + return isSilentRefresh || isRefreshing; + } + + return !isSilentRefresh && isRefreshing; + } + + get showRefreshIndicator(): boolean { + if (!this.spec.refreshIndicator) { + return false; + } + + return this.isRefreshing; } } diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/PaginationController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/PaginationController.ts index 28a966e029..454c47a674 100644 --- a/packages/pluggableWidgets/datagrid-web/src/controllers/PaginationController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/controllers/PaginationController.ts @@ -1,7 +1,7 @@ +import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { PaginationEnum, ShowPagingButtonsEnum } from "../../typings/DatagridProps"; -import { QueryController } from "./query-controller"; type Gate = DerivedPropsGate<{ pageSize: number; diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/StateSyncController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/StateSyncController.ts deleted file mode 100644 index 397c73e3c6..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/controllers/StateSyncController.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { compactArray, fromCompactArray, isAnd } from "@mendix/widget-plugin-filtering/condition-utils"; -import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; -import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { FilterCondition } from "mendix/filters"; -import { and } from "mendix/filters/builders"; -import { makeAutoObservable, reaction } from "mobx"; -import { SortInstruction } from "../typings/sorting"; -import { QueryController } from "./query-controller"; - -interface Columns { - conditions: Array; - sortInstructions: SortInstruction[] | undefined; -} - -interface Header { - conditions: Array; -} - -type StateSyncControllerSpec = { - query: QueryController; - columns: Columns; - header: Header; -}; - -export class StateSyncController implements ReactiveController { - private columns: Columns; - private header: Header; - private query: QueryController; - - constructor(host: ReactiveControllerHost, spec: StateSyncControllerSpec) { - host.addController(this); - this.columns = spec.columns; - this.header = spec.header; - this.query = spec.query; - - makeAutoObservable(this, { setup: false }); - } - - private get derivedFilter(): FilterCondition | undefined { - const { columns, header } = this; - - return and(compactArray(columns.conditions), compactArray(header.conditions)); - } - - private get derivedSortOrder(): SortInstruction[] | undefined { - return this.columns.sortInstructions; - } - - setup(): () => void { - const [add, disposeAll] = disposeBatch(); - add( - reaction( - () => this.derivedSortOrder, - sortOrder => this.query.setSortOrder(sortOrder), - { fireImmediately: true } - ) - ); - add( - reaction( - () => this.derivedFilter, - filter => this.query.setFilter(filter), - { fireImmediately: true } - ) - ); - - return disposeAll; - } - - static unzipFilter( - filter?: FilterCondition - ): [columns: Array, header: Array] { - if (!filter) { - return [[], []]; - } - if (!isAnd(filter)) { - return [[], []]; - } - if (filter.args.length !== 2) { - return [[], []]; - } - const [columns, header] = filter.args; - return [fromCompactArray(columns), fromCompactArray(header)]; - } -} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts index 2b64407749..7898b5a76e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts @@ -1,7 +1,7 @@ import { isAvailable } from "@mendix/widget-plugin-platform/framework/is-available"; import Big from "big.js"; import { ListValue, ObjectItem, ValueStatus } from "mendix"; -import { Emitter, Unsubscribe, createNanoEvents } from "nanoevents"; +import { createNanoEvents, Emitter, Unsubscribe } from "nanoevents"; import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; type RowData = Array; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/checkbox.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/checkbox.spec.tsx index 9bd449e1de..b2b972f6ba 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/checkbox.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/checkbox.spec.tsx @@ -1,6 +1,6 @@ import { createElement } from "react"; import userEvent, { UserEvent } from "@testing-library/user-event"; -import { render, RenderResult, fireEvent } from "@testing-library/react"; +import { fireEvent, render, RenderResult } from "@testing-library/react"; import { objectItems } from "@mendix/widget-plugin-test-utils"; import { eventSwitch } from "@mendix/widget-plugin-grid/event-switch/event-switch"; import { CheckboxContext } from "../base"; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/checkbox-handlers.ts b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/checkbox-handlers.ts index 2fa1539124..02c8113367 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/checkbox-handlers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/checkbox-handlers.ts @@ -1,10 +1,10 @@ import { ElementEntry, EventCaseEntry } from "@mendix/widget-plugin-grid/event-switch/base"; import { + onSelectAdjacentHotKey, + onSelectAllHotKey, SelectAdjacentFx, SelectAllFx, - SelectFx, - onSelectAdjacentHotKey, - onSelectAllHotKey + SelectFx } from "@mendix/widget-plugin-grid/selection"; import { CheckboxContext } from "./base"; import { blockUserSelect, unblockUserSelect } from "@mendix/widget-plugin-grid/selection/utils"; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/select-handlers.ts b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/select-handlers.ts index 44fc90531f..36ebdcc9b7 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/select-handlers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/select-handlers.ts @@ -1,11 +1,11 @@ import { ElementEntry, EventCaseEntry } from "@mendix/widget-plugin-grid/event-switch/base"; import { - SelectAdjacentFx, - SelectAllFx, - SelectFx, isSelectOneTrigger, onSelectAdjacentHotKey, - onSelectAllHotKey + onSelectAllHotKey, + SelectAdjacentFx, + SelectAllFx, + SelectFx } from "@mendix/widget-plugin-grid/selection"; import { blockUserSelect, removeAllRanges, unblockUserSelect } from "@mendix/widget-plugin-grid/selection/utils"; import { CellContext } from "./base"; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts new file mode 100644 index 0000000000..51386f8d90 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -0,0 +1,28 @@ +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; +import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { createContext, useContext } from "react"; +import { GridBasicData } from "../helpers/state/GridBasicData"; +import { EventsController } from "../typings/CellComponent"; +import { SelectActionHelper } from "./SelectActionHelper"; + +export interface DatagridRootScope { + basicData: GridBasicData; + // Controllers + selectionHelper: SelectionHelper | undefined; + selectActionHelper: SelectActionHelper; + cellEventsController: EventsController; + checkboxEventsController: EventsController; + focusController: FocusTargetController; + selectionCountStore: SelectionCountStore; +} + +export const DatagridContext = createContext(null); + +export const useDatagridRootScope = (): DatagridRootScope => { + const contextValue = useContext(DatagridContext); + if (!contextValue) { + throw new Error("useDatagridRootScope must be used within a root context provider"); + } + return contextValue; +}; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts index efc8dd67ad..0c26e492db 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts @@ -1,6 +1,9 @@ -import { disposeFx } from "@mendix/widget-plugin-filtering/mobx-utils"; -import { FiltersSettingsMap } from "@mendix/widget-plugin-filtering/typings/settings"; -import { FilterCondition } from "mendix/filters"; +import { reduceArray, restoreArray } from "@mendix/filter-commons/condition-utils"; +import { FiltersSettingsMap } from "@mendix/filter-commons/typings/settings"; +import { ConditionWithMeta } from "@mendix/widget-plugin-filtering/typings/ConditionWithMeta"; +import { ObservableFilterHost } from "@mendix/widget-plugin-filtering/typings/ObservableFilterHost"; +import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; + import { action, computed, makeObservable, observable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { ColumnId, GridColumn } from "../../typings/GridColumn"; @@ -40,23 +43,24 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { readonly columnFilters: ColumnFilterStore[]; + readonly metaKey = "ColumnGroupStore"; + sorting: ColumnsSortingStore; isResizing = false; constructor( props: Pick, info: StaticInfo, - dsViewState: Array | null + filterHost: ObservableFilterHost ) { this._allColumns = []; this.columnFilters = []; props.columns.forEach((columnProps, i) => { - const initCond = dsViewState?.at(i) ?? null; const column = new ColumnStore(i, columnProps, this); this._allColumnsById.set(column.columnId, column); this._allColumns[i] = column; - this.columnFilters[i] = new ColumnFilterStore(columnProps, info, initCond); + this.columnFilters[i] = new ColumnFilterStore(columnProps, info, filterHost); }); this.sorting = new ColumnsSortingStore( @@ -71,20 +75,21 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { _allColumnsOrdered: computed, availableColumns: computed, visibleColumns: computed, - conditions: computed.struct, + condWithMeta: computed, columnSettings: computed.struct, filterSettings: computed({ keepAlive: true }), updateProps: action, setIsResizing: action, swapColumns: action, - setColumnSettings: action + setColumnSettings: action, + hydrate: action }); } setup(): () => void { - const [disposers, dispose] = disposeFx(); + const [add, dispose] = disposeBatch(); for (const filter of this.columnFilters) { - disposers.push(filter.setup()); + add(filter.setup()); } return dispose; } @@ -92,7 +97,6 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { updateProps(props: Pick): void { props.columns.forEach((columnProps, i) => { this._allColumns[i].updateProps(columnProps); - this.columnFilters[i].updateProps(columnProps); }); if (this.visibleColumns.length < 1) { @@ -142,12 +146,6 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { return [...this.availableColumns].filter(column => !column.isHidden); } - get conditions(): Array { - return this.columnFilters.map((store, index) => { - return this._allColumns[index].isHidden ? undefined : store.condition2; - }); - } - get sortInstructions(): SortInstruction[] | undefined { return sortRulesToSortInstructions(this.sorting.rules, this._allColumns); } @@ -194,4 +192,21 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { isLastVisible(column: ColumnStore): boolean { return this.visibleColumns.at(-1) === column; } + + get condWithMeta(): ConditionWithMeta { + const conditions = this.columnFilters.map((store, index) => { + return this._allColumns[index].isHidden ? undefined : store.condition; + }); + const [cond, meta] = reduceArray(conditions); + return { cond, meta }; + } + + hydrate({ cond, meta }: ConditionWithMeta): void { + restoreArray(cond, meta).forEach((condition, index) => { + const filter = this.columnFilters[index]; + if (filter && condition) { + filter.fromViewState(condition); + } + }); + } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts new file mode 100644 index 0000000000..1b0b1ed909 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -0,0 +1,50 @@ +import { SelectionHelper, SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { makeAutoObservable } from "mobx"; +import { DatagridContainerProps } from "../../../typings/DatagridProps"; + +type Props = Pick< + DatagridContainerProps, + "exportDialogLabel" | "cancelExportLabel" | "selectRowLabel" | "selectAllRowsLabel" | "itemSelection" | "onClick" +>; + +type Gate = DerivedPropsGate; + +/** This is basic data class, just a props mapper. Don't add any state or complex logic. */ +export class GridBasicData { + private gate: Gate; + private selectionHelper: SelectionHelper | null = null; + + constructor(gate: Gate) { + this.gate = gate; + makeAutoObservable(this); + } + + get exportDialogLabel(): string | undefined { + return this.gate.props.exportDialogLabel?.value; + } + + get cancelExportLabel(): string | undefined { + return this.gate.props.cancelExportLabel?.value; + } + + get selectRowLabel(): string | undefined { + return this.gate.props.selectRowLabel?.value; + } + + get selectAllRowsLabel(): string | undefined { + return this.gate.props.selectAllRowsLabel?.value; + } + + get gridInteractive(): boolean { + return !!(this.gate.props.itemSelection || this.gate.props.onClick); + } + + get selectionStatus(): SelectionStatus { + return this.selectionHelper?.type === "Multi" ? this.selectionHelper.selectionStatus : "none"; + } + + setSelectionHelper(selectionHelper: SelectionHelper | undefined): void { + this.selectionHelper = selectionHelper ?? null; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts index 21c8024aca..0fe509c9cc 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts @@ -1,6 +1,6 @@ +import { FiltersSettingsMap } from "@mendix/filter-commons/typings/settings"; import { error, Result, value } from "@mendix/widget-plugin-filtering/result-meta"; -import { HeaderFiltersStore } from "@mendix/widget-plugin-filtering/stores/generic/HeaderFiltersStore"; -import { FiltersSettingsMap } from "@mendix/widget-plugin-filtering/typings/settings"; +import { ObservableFilterHost } from "@mendix/widget-plugin-filtering/typings/ObservableFilterHost"; import { action, comparer, computed, IReactionDisposer, makeObservable, reaction } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { ColumnId } from "../../typings/GridColumn"; @@ -14,10 +14,16 @@ import { AttributePersonalizationStorage } from "../storage/AttributePersonaliza import { LocalStoragePersonalizationStorage } from "../storage/LocalStoragePersonalizationStorage"; import { PersonalizationStorage } from "../storage/PersonalizationStorage"; import { ColumnGroupStore } from "./ColumnGroupStore"; + +type RequiredProps = Pick< + DatagridContainerProps, + "name" | "configurationStorageType" | "storeFiltersInPersonalization" | "configurationAttribute" +>; + export class GridPersonalizationStore { private readonly gridName: string; private readonly gridColumnsHash: string; - private readonly schemaVersion: GridPersonalizationStorageSettings["schemaVersion"] = 2; + private readonly schemaVersion: GridPersonalizationStorageSettings["schemaVersion"] = 3; private readonly storeFilters: boolean; private storage: PersonalizationStorage; @@ -25,9 +31,9 @@ export class GridPersonalizationStore { private disposers: IReactionDisposer[] = []; constructor( - props: DatagridContainerProps, + props: RequiredProps, private columnsStore: ColumnGroupStore, - private headerFilters: HeaderFiltersStore + private customFilters: ObservableFilterHost ) { this.gridName = props.name; this.gridColumnsHash = getHash(this.columnsStore._allColumns, this.gridName); @@ -35,7 +41,6 @@ export class GridPersonalizationStore { makeObservable(this, { settings: computed, - applySettings: action }); @@ -53,7 +58,7 @@ export class GridPersonalizationStore { this.disposers.forEach(d => d()); } - updateProps(props: DatagridContainerProps): void { + updateProps(props: RequiredProps): void { this.storage.updateProps?.(props); } @@ -94,7 +99,10 @@ export class GridPersonalizationStore { private applySettings(settings: GridPersonalizationStorageSettings): void { this.columnsStore.setColumnSettings(toColumnSettings(settings)); - this.columnsStore.setColumnFilterSettings(settings.columnFilters); + if (this.storeFilters) { + this.columnsStore.setColumnFilterSettings(settings.columnFilters); + this.customFilters.settings = new Map(settings.customFilters); + } } private readSettings( @@ -137,7 +145,7 @@ export class GridPersonalizationStore { this.gridColumnsHash, this.columnsStore.columnSettings, this.storeFilters ? this.columnsStore.filterSettings : new Map(), - this.storeFilters ? this.headerFilters.settings : new Map() + this.storeFilters ? this.customFilters.settings : new Map() ); } } @@ -164,7 +172,7 @@ function toStorageFormat( gridColumnsHash: string, columnsSettings: ColumnPersonalizationSettings[], columnFilters: FiltersSettingsMap, - groupFilters: FiltersSettingsMap + customFilters: FiltersSettingsMap ): GridPersonalizationStorageSettings { const sortOrder = columnsSettings .filter(c => c.sortDir && c.sortWeight !== undefined) @@ -175,7 +183,7 @@ function toStorageFormat( return { name: gridName, - schemaVersion: 2, + schemaVersion: 3, settingsHash: gridColumnsHash, columns: columnsSettings.map(c => ({ columnId: c.columnId, @@ -185,7 +193,7 @@ function toStorageFormat( })), columnFilters: Array.from(columnFilters), - groupFilters: Array.from(groupFilters), + customFilters: Array.from(customFilters), sortOrder, columnOrder diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index a98cbf7eff..7e64c08ec0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -1,21 +1,42 @@ -import { HeaderFiltersStore } from "@mendix/widget-plugin-filtering/stores/generic/HeaderFiltersStore"; +import { createContextWithStub, FilterAPI } from "@mendix/widget-plugin-filtering/context"; +import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; +import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; +import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; +import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; -import { autorun, computed } from "mobx"; +import { autorun } from "mobx"; +import { GridBasicData } from "src/helpers/state/GridBasicData"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; -import { DatasourceController } from "../../controllers/DatasourceController"; +import { DatasourceParamsController } from "../../controllers/DatasourceParamsController"; import { DerivedLoaderController } from "../../controllers/DerivedLoaderController"; import { PaginationController } from "../../controllers/PaginationController"; -import { RefreshController } from "../../controllers/RefreshController"; -import { StateSyncController } from "../../controllers/StateSyncController"; import { ProgressStore } from "../../features/data-export/ProgressStore"; import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; -type Gate = DerivedPropsGate; +type RequiredProps = Pick< + DatagridContainerProps, + | "name" + | "datasource" + | "refreshInterval" + | "refreshIndicator" + | "itemSelection" + | "columns" + | "configurationStorageType" + | "storeFiltersInPersonalization" + | "configurationAttribute" + | "pageSize" + | "pagination" + | "showPagingButtons" + | "showNumberOfRows" +>; + +type Gate = DerivedPropsGate; type Spec = { gate: Gate; @@ -24,63 +45,87 @@ type Spec = { export class RootGridStore extends BaseControllerHost { columnsStore: ColumnGroupStore; - headerFiltersStore: HeaderFiltersStore; settingsStore: GridPersonalizationStore; + selectionCountStore: SelectionCountStore; + basicData: GridBasicData; staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; + readonly filterAPI: FilterAPI; private gate: Gate; constructor({ gate, exportCtrl }: Spec) { super(); - const { props } = gate; - const [columnsViewState, headerViewState] = StateSyncController.unzipFilter(props.datasource.filter); this.gate = gate; + this.staticInfo = { name: props.name, filtersChannelName: `datagrid/${generateUUID()}` }; + + const filterHost = new CustomFilterHost(); + const query = new DatasourceController(this, { gate }); - const columns = (this.columnsStore = new ColumnGroupStore(props, this.staticInfo, columnsViewState)); - const header = (this.headerFiltersStore = new HeaderFiltersStore(props, this.staticInfo, headerViewState)); - this.settingsStore = new GridPersonalizationStore(props, this.columnsStore, this.headerFiltersStore); + + this.filterAPI = createContextWithStub({ + filterObserver: filterHost, + parentChannelName: this.staticInfo.filtersChannelName + }); + + this.columnsStore = new ColumnGroupStore(props, this.staticInfo, filterHost); + + const combinedFilter = new CombinedFilter(this, { + stableKey: props.name, + inputs: [filterHost, this.columnsStore] + }); + + this.settingsStore = new GridPersonalizationStore(props, this.columnsStore, filterHost); + + this.basicData = new GridBasicData(gate); + + this.selectionCountStore = new SelectionCountStore(gate); + this.paginationCtrl = new PaginationController(this, { gate, query }); + this.exportProgressCtrl = exportCtrl; - new StateSyncController(this, { + new DatasourceParamsController(this, { query, - columns, - header + filterHost: combinedFilter, + sortHost: this.columnsStore }); new RefreshController(this, { - query: computed(() => query.computedCopy), + query: query.derivedQuery, delay: props.refreshInterval * 1000 }); this.loaderCtrl = new DerivedLoaderController({ exp: exportCtrl, - cols: columns, + cols: this.columnsStore, + showSilentRefresh: props.refreshInterval > 1, + refreshIndicator: props.refreshIndicator, query }); + + combinedFilter.hydrate(props.datasource.filter); } setup(): () => void { const [add, disposeAll] = disposeBatch(); add(super.setup()); add(this.columnsStore.setup()); - add(this.headerFiltersStore.setup() ?? (() => {})); add(() => this.settingsStore.dispose()); add(autorun(() => this.updateProps(this.gate.props))); return disposeAll; } - private updateProps(props: DatagridContainerProps): void { + private updateProps(props: RequiredProps): void { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx index 9d0d9ae7cb..5007107de0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx @@ -1,105 +1,83 @@ -import { FilterAPIv2, getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; -import { RefFilterStore, RefFilterStoreProps } from "@mendix/widget-plugin-filtering/stores/picker/RefFilterStore"; -import { StaticSelectFilterStore } from "@mendix/widget-plugin-filtering/stores/picker/StaticSelectFilterStore"; -import { InputFilterStore, attrgroupFilterStore } from "@mendix/widget-plugin-filtering/stores/input/store-utils"; -import { ensure } from "@mendix/widget-plugin-platform/utils/ensure"; +import { FilterData } from "@mendix/filter-commons/typings/settings"; +import { EnumFilterStore } from "@mendix/widget-plugin-dropdown-filter/stores/EnumFilterStore"; +import { FilterAPI, getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; +import { APIError } from "@mendix/widget-plugin-filtering/errors"; +import { error, value } from "@mendix/widget-plugin-filtering/result-meta"; +import { attrgroupFilterStore, InputFilterStore } from "@mendix/widget-plugin-filtering/stores/input/store-utils"; +import { ObservableFilterHost } from "@mendix/widget-plugin-filtering/typings/ObservableFilterHost"; +import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; +import { ListAttributeListValue, ListAttributeValue } from "mendix"; import { FilterCondition } from "mendix/filters"; -import { ListAttributeValue, ListAttributeListValue } from "mendix"; -import { action, computed, makeObservable } from "mobx"; -import { ReactNode, createElement } from "react"; +import { computed, makeObservable } from "mobx"; +import { createElement, ReactNode } from "react"; import { ColumnsType } from "../../../../typings/DatagridProps"; import { StaticInfo } from "../../../typings/static-info"; -import { FilterData } from "@mendix/widget-plugin-filtering/typings/settings"; -import { value } from "@mendix/widget-plugin-filtering/result-meta"; -import { disposeFx } from "@mendix/widget-plugin-filtering/mobx-utils"; + export interface IColumnFilterStore { renderFilterWidgets(): ReactNode; } -type FilterStore = InputFilterStore | StaticSelectFilterStore | RefFilterStore; +type FilterStore = InputFilterStore | EnumFilterStore; const { Provider } = getGlobalFilterContextObject(); export class ColumnFilterStore implements IColumnFilterStore { private _widget: ReactNode; + private _error: APIError | null; private _filterStore: FilterStore | null = null; - private _context: FilterAPIv2; + private _context: FilterAPI; + private _filterHost: ObservableFilterHost; - constructor(props: ColumnsType, info: StaticInfo, dsViewState: FilterCondition | null) { + constructor(props: ColumnsType, info: StaticInfo, filterHost: ObservableFilterHost) { + this._filterHost = filterHost; this._widget = props.filter; - this._filterStore = this.createFilterStore(props, dsViewState); + const storeResult = this.createFilterStore(props, null); + if (storeResult === null) { + this._error = this._filterStore = null; + } else if (storeResult.hasError) { + this._error = storeResult.error; + this._filterStore = null; + } else { + this._error = null; + this._filterStore = storeResult.value; + } this._context = this.createContext(this._filterStore, info); - makeObservable(this, { - _updateStore: action, - condition2: computed, - updateProps: action + makeObservable(this, { + condition: computed }); } setup(): () => void { - const [disposers, dispose] = disposeFx(); + const [add, disposeAll] = disposeBatch(); if (this._filterStore && "setup" in this._filterStore) { - disposers.push(this._filterStore.setup()); - } - return dispose; - } - - updateProps(props: ColumnsType): void { - this._widget = props.filter; - this._updateStore(props); - } - - private _updateStore(props: ColumnsType): void { - const store = this._filterStore; - - if (store === null) { - return; - } - - if (store.storeType === "refselect") { - store.updateProps(this.toRefselectProps(props)); - } else if (isListAttributeValue(props.attribute)) { - store.updateProps([props.attribute]); + add(this._filterStore.setup()); } + return disposeAll; } - private toRefselectProps(props: ColumnsType): RefFilterStoreProps { - const searchAttrId = props.filterAssociationOptionLabelAttr?.id; - const caption = - props.filterCaptionType === "expression" - ? ensure(props.filterAssociationOptionLabel, errorMessage("filterAssociationOptionLabel")) - : ensure(props.filterAssociationOptionLabelAttr, errorMessage("filterAssociationOptionLabelAttr")); - - return { - ref: ensure(props.filterAssociation, errorMessage("filterAssociation")), - datasource: ensure(props.filterAssociationOptions, errorMessage("filterAssociationOptions")), - searchAttrId, - fetchOptionsLazy: props.fetchOptionsLazy, - caption - }; - } - - private createFilterStore(props: ColumnsType, dsViewState: FilterCondition | null): FilterStore | null { - if (props.filterAssociation) { - return new RefFilterStore(this.toRefselectProps(props), dsViewState); - } - + private createFilterStore( + props: ColumnsType, + dsViewState: FilterCondition | null + ): ReturnType | null { if (isListAttributeValue(props.attribute)) { - return attrgroupFilterStore(props.attribute.type, [props.attribute], dsViewState); + return attrgroupFilterStore(props.attribute.type, props.attribute, dsViewState); } return null; } - private createContext(store: FilterStore | null, info: StaticInfo): FilterAPIv2 { + private createContext(store: FilterStore | null, info: StaticInfo): FilterAPI { return { - version: 2, + version: 3, parentChannelName: info.filtersChannelName, - provider: value({ - type: "direct", - store - }) + provider: this._error + ? error(this._error) + : value({ + type: "direct", + store + }), + filterObserver: this._filterHost }; } @@ -107,7 +85,11 @@ export class ColumnFilterStore implements IColumnFilterStore { return {this._widget}; } - get condition2(): FilterCondition | undefined { + fromViewState(cond: FilterCondition): void { + this._filterStore?.fromViewState(cond); + } + + get condition(): FilterCondition | undefined { return this._filterStore ? this._filterStore.condition : undefined; } @@ -129,6 +111,3 @@ const isListAttributeValue = ( ): attribute is ListAttributeValue => { return !!(attribute && attribute.isList === false); }; - -const errorMessage = (propName: string): string => - `Can't map ColumnsType to AssociationProperties: ${propName} is undefined`; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx index 6dbad6101b..95aa9c9edb 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx @@ -1,7 +1,7 @@ import { DynamicValue, - ListAttributeValue, ListAttributeListValue, + ListAttributeValue, ListExpressionValue, ListWidgetValue, ObjectItem, diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts index 33255f082c..0eb8f515a4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts @@ -1,12 +1,18 @@ import { EditableValue, ValueStatus } from "mendix"; -import { PersonalizationStorage } from "./PersonalizationStorage"; import { action, computed, makeObservable, observable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { PersonalizationStorage } from "./PersonalizationStorage"; + +type RequiredProps = Pick; +/** + * AttributePersonalizationStorage is a class that implements PersonalizationStorage + * and uses an editable value to store the personalization settings in a Mendix attribute. + */ export class AttributePersonalizationStorage implements PersonalizationStorage { private _storageAttr: EditableValue | undefined; - constructor(props: Pick) { + constructor(props: RequiredProps) { this._storageAttr = props.configurationAttribute; makeObservable(this, { @@ -17,7 +23,7 @@ export class AttributePersonalizationStorage implements PersonalizationStorage { }); } - updateProps(props: Pick): void { + updateProps(props: RequiredProps): void { this._storageAttr = props.configurationAttribute; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts index 4c736bbb2f..4b6f170c54 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts @@ -1,7 +1,9 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; +type RequiredProps = Pick; + export interface PersonalizationStorage { settings: unknown; updateSettings(newSettings: any): void; - updateProps?(props: DatagridContainerProps): void; + updateProps?(props: RequiredProps): void; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts new file mode 100644 index 0000000000..e825120155 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts @@ -0,0 +1,11 @@ +import { useOnClearSelectionEvent, useOnResetFiltersEvent } from "@mendix/widget-plugin-external-events/hooks"; +import { SelectActionHelper } from "./SelectActionHelper"; +import { RootGridStore } from "./state/RootGridStore"; + +export function useDataGridJSActions(root: RootGridStore, selectActionHelper?: SelectActionHelper): void { + useOnResetFiltersEvent(root.staticInfo.name, root.staticInfo.filtersChannelName); + useOnClearSelectionEvent({ + widgetName: root.staticInfo.name, + listener: () => selectActionHelper?.onClearSelection() + }); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts deleted file mode 100644 index 15f75cdc97..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ObjectItem } from "mendix"; -import { createContext, Provider, useContext } from "react"; -import { GridColumn } from "../typings/GridColumn"; -import { WidgetProps } from "../components/Widget"; - -const NO_PROPS_VALUE = Symbol("NO_PROPS_VALUE"); - -type Props = WidgetProps; -type ContextValue = typeof NO_PROPS_VALUE | Props; - -const context = createContext(NO_PROPS_VALUE); - -export const WidgetPropsProvider: Provider = context.Provider; - -export function useWidgetProps(): Props { - const value = useContext(context); - - if (value === NO_PROPS_VALUE) { - throw new Error("useTableProps: failed to get value from props provider."); - } - - return value; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/package.xml b/packages/pluggableWidgets/datagrid-web/src/package.xml index b2c27bcd1e..e84d7ed386 100644 --- a/packages/pluggableWidgets/datagrid-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-web/src/typings/personalization-settings.ts b/packages/pluggableWidgets/datagrid-web/src/typings/personalization-settings.ts index f51a4f9b4e..663dcb3b32 100644 --- a/packages/pluggableWidgets/datagrid-web/src/typings/personalization-settings.ts +++ b/packages/pluggableWidgets/datagrid-web/src/typings/personalization-settings.ts @@ -1,4 +1,4 @@ -import { FilterData } from "@mendix/widget-plugin-filtering/typings/settings"; +import { FilterData } from "@mendix/filter-commons/typings/settings"; import { ColumnId } from "./GridColumn"; import { SortDirection, SortRule } from "./sorting"; @@ -19,14 +19,14 @@ interface ColumnPersonalizationStorageSettings { export type ColumnFilterSettings = Array<[key: ColumnId, data: FilterData]>; -export type GroupFilterSettings = Array<[key: string, data: FilterData]>; +export type CustomFilterSettings = Array<[key: string, data: FilterData]>; export interface GridPersonalizationStorageSettings { name: string; - schemaVersion: 2; + schemaVersion: 3; settingsHash: string; columns: ColumnPersonalizationStorageSettings[]; - groupFilters: GroupFilterSettings; + customFilters: CustomFilterSettings; columnFilters: ColumnFilterSettings; columnOrder: ColumnId[]; sortOrder: SortRule[]; diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts b/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts index 874bed0ab0..00d715be9f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts +++ b/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts @@ -1,25 +1,6 @@ -/* eslint-disable no-bitwise */ +import { fnv1aHash } from "@mendix/widget-plugin-grid/utils/fnv-1a-hash"; import { GridColumn } from "../typings/GridColumn"; -/** - * Generates 32 bit FNV-1a hash from the given string. - * As explained here: http://isthe.com/chongo/tech/comp/fnv/ - * - * @param s {string} String to generate hash from. - * @param [h] {number} FNV-1a hash generation init value. - * @returns {number} The result integer hash. - */ -function hash(s: string, h = 0x811c9dc5): number { - const l = s.length; - - for (let i = 0; i < l; i++) { - h ^= s.charCodeAt(i); - h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); - } - - return h >>> 0; -} - export function getHash(columns: GridColumn[], gridName: string): string { const data = JSON.stringify({ name: gridName, @@ -31,5 +12,5 @@ export function getHash(columns: GridColumn[], gridName: string): string { canDrag: col.canDrag })) }); - return hash(data).toString(); + return fnv1aHash(data).toString(); } diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index 3a9a345e20..bc9e5953d4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -1,16 +1,16 @@ -import { createElement } from "react"; -import { GUID, ObjectItem } from "mendix"; +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; +import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; +import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { dynamicValue, listAttr, listExp } from "@mendix/widget-plugin-test-utils"; -import { WidgetProps } from "../components/Widget"; +import { GUID, ObjectItem } from "mendix"; +import { createElement } from "react"; import { ColumnsType } from "../../typings/DatagridProps"; import { Cell } from "../components/Cell"; -import { ColumnId, GridColumn } from "../typings/GridColumn"; +import { WidgetProps } from "../components/Widget"; import { SelectActionHelper } from "../helpers/SelectActionHelper"; -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; -import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { ColumnStore } from "../helpers/state/column/ColumnStore"; import { IColumnParentStore } from "../helpers/state/ColumnGroupStore"; +import { ColumnId, GridColumn } from "../typings/GridColumn"; export const column = (header = "Test", patch?: (col: ColumnsType) => void): ColumnsType => { const c: ColumnsType = { @@ -29,9 +29,7 @@ export const column = (header = "Test", patch?: (col: ColumnsType) => void): Col visible: dynamicValue(true), minWidth: "auto", minWidthLimit: 100, - allowEventPropagation: true, - fetchOptionsLazy: true, - filterCaptionType: "attribute" + allowEventPropagation: true }; if (patch) { @@ -100,17 +98,16 @@ export function mockWidgetProps(): WidgetProps { availableColumns: columns, columnsSwap: jest.fn(), setIsResizing: jest.fn(), - selectionStatus: "unknown", setPage: jest.fn(), processedRows: 0, - gridInteractive: false, selectActionHelper: mockSelectionProps(), cellEventsController: { getProps: () => Object.create({}) }, checkboxEventsController: { getProps: () => Object.create({}) }, - isLoading: false, + isFirstLoad: false, isFetchingNextBatch: false, loadingType: "spinner", columnsLoading: false, + showRefreshIndicator: false, focusController: new FocusTargetController( new PositionController(), new VirtualGridLayout(1, columns.length, 10) diff --git a/packages/pluggableWidgets/datagrid-web/test-ct/preview.spec.tsx b/packages/pluggableWidgets/datagrid-web/test-ct/preview.spec.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 1935eef553..a3e01d2e2d 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -4,7 +4,7 @@ * @author Mendix Widgets Framework Team */ import { ComponentType, CSSProperties, ReactNode } from "react"; -import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListReferenceValue, ListReferenceSetValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; +import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; @@ -15,8 +15,6 @@ export type LoadingTypeEnum = "spinner" | "skeleton"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; -export type FilterCaptionTypeEnum = "attribute" | "expression"; - export type HidableEnum = "yes" | "hidden" | "no"; export type WidthEnum = "autoFill" | "autoFit" | "manual"; @@ -35,12 +33,6 @@ export interface ColumnsType { tooltip?: ListExpressionValue; filter?: ReactNode; visible: DynamicValue; - filterAssociation?: ListReferenceValue | ListReferenceSetValue; - filterAssociationOptions?: ListValue; - fetchOptionsLazy: boolean; - filterCaptionType: FilterCaptionTypeEnum; - filterAssociationOptionLabel?: ListExpressionValue; - filterAssociationOptionLabelAttr?: ListAttributeValue; sortable: boolean; resizable: boolean; draggable: boolean; @@ -67,10 +59,6 @@ export type OnClickTriggerEnum = "single" | "double"; export type ConfigurationStorageTypeEnum = "attribute" | "localStorage"; -export interface FilterListType { - filter: ListAttributeValue; -} - export interface ColumnsPreviewType { showContentAs: ShowContentAsEnum; attribute: string; @@ -81,12 +69,6 @@ export interface ColumnsPreviewType { tooltip: string; filter: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; visible: string; - filterAssociation: string; - filterAssociationOptions: {} | { caption: string } | { type: string } | null; - fetchOptionsLazy: boolean; - filterCaptionType: FilterCaptionTypeEnum; - filterAssociationOptionLabel: string; - filterAssociationOptionLabelAttr: string; sortable: boolean; resizable: boolean; draggable: boolean; @@ -101,10 +83,6 @@ export interface ColumnsPreviewType { wrapText: boolean; } -export interface FilterListPreviewType { - filter: string; -} - export interface DatagridContainerProps { name: string; class: string; @@ -117,7 +95,9 @@ export interface DatagridContainerProps { itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; + keepSelection: boolean; loadingType: LoadingTypeEnum; + refreshIndicator: boolean; columns: ColumnsType[]; columnsFilterable: boolean; pageSize: number; @@ -132,6 +112,7 @@ export interface DatagridContainerProps { onClickTrigger: OnClickTriggerEnum; onClick?: ListActionValue; onSelectionChange?: ActionValue; + filtersPlaceholder?: ReactNode; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; @@ -139,13 +120,13 @@ export interface DatagridContainerProps { configurationStorageType: ConfigurationStorageTypeEnum; configurationAttribute?: EditableValue; storeFiltersInPersonalization: boolean; - filterList: FilterListType[]; - filtersPlaceholder?: ReactNode; filterSectionTitle?: DynamicValue; exportDialogLabel?: DynamicValue; cancelExportLabel?: DynamicValue; selectRowLabel?: DynamicValue; selectAllRowsLabel?: DynamicValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; } export interface DatagridPreviewProps { @@ -166,7 +147,9 @@ export interface DatagridPreviewProps { itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; + keepSelection: boolean; loadingType: LoadingTypeEnum; + refreshIndicator: boolean; columns: ColumnsPreviewType[]; columnsFilterable: boolean; pageSize: number | null; @@ -181,6 +164,7 @@ export interface DatagridPreviewProps { onClickTrigger: OnClickTriggerEnum; onClick: {} | null; onSelectionChange: {} | null; + filtersPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; @@ -189,11 +173,11 @@ export interface DatagridPreviewProps { configurationAttribute: string; storeFiltersInPersonalization: boolean; onConfigurationChange: {} | null; - filterList: FilterListPreviewType[]; - filtersPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; filterSectionTitle: string; exportDialogLabel: string; cancelExportLabel: string; selectRowLabel: string; selectAllRowsLabel: string; + selectedCountTemplateSingular: string; + selectedCountTemplatePlural: string; } diff --git a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md index d4eefaf062..50c3ff2f17 100644 --- a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md +++ b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [1.1.0] - 2025-09-11 + +### Changed + +- We moved the bundle of pdfjs worker to local build instead of using CDN for better CSP compliance. + ## [1.0.0] - 2025-05-05 ### Added diff --git a/packages/pluggableWidgets/document-viewer-web/components/PDFViewer.tsx b/packages/pluggableWidgets/document-viewer-web/components/PDFViewer.tsx index 1f5549b5c5..902d980593 100644 --- a/packages/pluggableWidgets/document-viewer-web/components/PDFViewer.tsx +++ b/packages/pluggableWidgets/document-viewer-web/components/PDFViewer.tsx @@ -6,10 +6,15 @@ import { downloadFile } from "../utils/helpers"; import { useZoomScale } from "../utils/useZoomScale"; import BaseViewer from "./BaseViewer"; import { DocRendererElement, DocumentRendererProps, DocumentStatus } from "./documentRenderer"; -pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "/widgets/com/mendix/shared/pdfjs/pdf.worker.mjs", + import.meta.url +).toString(); + const options = { - cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, - standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts` + cMapUrl: "/widgets/com/mendix/shared/pdfjs/cmaps/", + standardFontDataUrl: "/widgets/com/mendix/shared/pdfjs/standard_fonts" }; const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { diff --git a/packages/pluggableWidgets/document-viewer-web/e2e/DocumentViewer.spec.js b/packages/pluggableWidgets/document-viewer-web/e2e/DocumentViewer.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/pluggableWidgets/document-viewer-web/package.json b/packages/pluggableWidgets/document-viewer-web/package.json index 23b2732bb8..2a582c5a93 100644 --- a/packages/pluggableWidgets/document-viewer-web/package.json +++ b/packages/pluggableWidgets/document-viewer-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/document-viewer-web", "widgetName": "DocumentViewer", - "version": "1.0.0", + "version": "1.1.0", "description": "View PDF and other document types", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", @@ -25,27 +25,39 @@ "appName": "Document Viewer", "reactReady": true }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "document-viewer-web" + }, "scripts": { "build": "pluggable-widgets-tools build:web", + "create-gh-release": "rui-create-gh-release", + "create-translation": "rui-create-translation", "dev": "pluggable-widgets-tools start:web", "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", "lint": "eslint src/ package.json", + "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "echo 'FIXME: Add unit tests'" + "test": "echo 'FIXME: Add unit tests'", + "update-changelog": "rui-update-changelog-widget", + "verify": "rui-verify-package-format" }, "dependencies": { - "classnames": "^2.3.2", - "docx-preview": "^0.3.5", - "pdfjs-dist": "^5.0.375", + "@mendix/widget-plugin-component-kit": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", + "classnames": "^2.5.1", + "docx-preview": "^0.3.6", + "pdfjs-dist": "4.8.69", "react-pdf": "^9.2.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@mendix/pluggable-widgets-tools": "^10.0.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@mendix/pluggable-widgets-tools": "*", + "@mendix/rollup-web-widgets": "workspace:*", "@rollup/plugin-replace": "^6.0.2" } } diff --git a/packages/pluggableWidgets/document-viewer-web/rollup.config.mjs b/packages/pluggableWidgets/document-viewer-web/rollup.config.mjs index 126825bde1..c22145f8f9 100644 --- a/packages/pluggableWidgets/document-viewer-web/rollup.config.mjs +++ b/packages/pluggableWidgets/document-viewer-web/rollup.config.mjs @@ -1,5 +1,7 @@ import commonjs from "@rollup/plugin-commonjs"; import replace from "@rollup/plugin-replace"; +import copy from "rollup-plugin-copy"; +import { copyDefaultFilesPlugin } from "@mendix/rollup-web-widgets/copyFiles.mjs"; export default args => { const result = args.configDefaultConfig; @@ -41,6 +43,26 @@ export default args => { // Tree-shake client worker initialization logic. "!PDFWorkerUtil.isWorkerDisabled && !PDFWorker.#mainThreadWorkerMessageHandler": "false" } + }), + copyDefaultFilesPlugin(), + copy({ + targets: [ + { + src: "node_modules/pdfjs-dist/cmaps", + dest: "dist/tmp/widgets/com/mendix/shared/pdfjs/", + flatten: false + }, + { + src: "node_modules/pdfjs-dist/standard_fonts", + dest: "dist/tmp/widgets/com/mendix/shared/pdfjs/", + flatten: false + }, + { + src: "node_modules/pdfjs-dist/build/pdf.worker.min.mjs", + dest: "dist/tmp/widgets/com/mendix/shared/pdfjs/", + rename: "pdf.worker.mjs" + } + ] }) ] }; diff --git a/packages/pluggableWidgets/document-viewer-web/src/DocumentViewer.editorConfig.ts b/packages/pluggableWidgets/document-viewer-web/src/DocumentViewer.editorConfig.ts index 2bd939da8f..aaf97f2e5c 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/DocumentViewer.editorConfig.ts +++ b/packages/pluggableWidgets/document-viewer-web/src/DocumentViewer.editorConfig.ts @@ -1,10 +1,10 @@ import { hidePropertiesIn, hidePropertyIn, Properties } from "@mendix/pluggable-widgets-tools"; import { + container, + rowLayout, RowLayoutProps, - StructurePreviewProps, structurePreviewPalette, - rowLayout, - container, + StructurePreviewProps, text } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { DocumentViewerPreviewProps } from "typings/DocumentViewerProps"; diff --git a/packages/pluggableWidgets/document-viewer-web/src/package.xml b/packages/pluggableWidgets/document-viewer-web/src/package.xml index 0b98a3e64e..64e37b95d6 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/package.xml +++ b/packages/pluggableWidgets/document-viewer-web/src/package.xml @@ -1,11 +1,11 @@ - + - + diff --git a/packages/pluggableWidgets/dropdown-sort-web/jest.config.cjs b/packages/pluggableWidgets/dropdown-sort-web/jest.config.cjs new file mode 100644 index 0000000000..22d837ab37 --- /dev/null +++ b/packages/pluggableWidgets/dropdown-sort-web/jest.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js"), + /** + * `nanoevents` package is ESM module and because ESM is not supported by Jest yet + * we mark `nanoevents` as a module that should be transformed by ts-jest. + */ + transformIgnorePatterns: ["node_modules/(?!nanoevents)/"] +}; diff --git a/packages/pluggableWidgets/dropdown-sort-web/package.json b/packages/pluggableWidgets/dropdown-sort-web/package.json index 02a0b022b8..87556d41f2 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/package.json +++ b/packages/pluggableWidgets/dropdown-sort-web/package.json @@ -1,8 +1,8 @@ { "name": "@mendix/dropdown-sort-web", "widgetName": "DropdownSort", - "version": "1.2.2", - "description": "", + "version": "3.3.0", + "description": "Adds sorting functionality to Gallery widget.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", "private": true, @@ -23,20 +23,20 @@ }, "testProject": { "githubUrl": "https://github.com/mendix/testProjects", - "branchName": "dropdown-sort-web/main" + "branchName": "dropdown-sort-web/data-widgets-3.0" }, "scripts": { "build": "pluggable-widgets-tools build:ts", "create-translation": "rui-create-translation", "dev": "pluggable-widgets-tools start:ts", "e2e": "run-e2e ci", - "e2e-update-project": "pnpm -w exec turbo run build:module --filter data-widgets --force", + "e2e-update-project": "pnpm --filter @mendix/data-widgets run build:include-deps", "e2edev": "run-e2e dev --with-preps", "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", "lint": "eslint src/ package.json", "release": "pluggable-widgets-tools release:ts", "start": "pluggable-widgets-tools start:server", - "test": "pluggable-widgets-tools test:unit:web", + "test": "jest", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, @@ -55,6 +55,7 @@ "@mendix/widget-plugin-sorting": "workspace:*", "@mendix/widget-plugin-test-utils": "workspace:*", "@types/enzyme": "^3.10.13", - "classnames": "^2.3.2" + "classnames": "^2.5.1", + "jest": "^29.7.0" } } diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorConfig.ts b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorConfig.ts index 8d897b639b..054615ede5 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorConfig.ts +++ b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorConfig.ts @@ -1,9 +1,9 @@ import { ContainerProps, ImageProps, + structurePreviewPalette, StructurePreviewProps, - text, - structurePreviewPalette + text } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { DropdownSortPreviewProps } from "../typings/DropdownSortProps"; diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorPreview.tsx b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorPreview.tsx index e2ed2b254a..b5c6aed298 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorPreview.tsx +++ b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.editorPreview.tsx @@ -1,11 +1,14 @@ +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; +import { withSortAPI } from "@mendix/widget-plugin-sorting/react/hocs/withSortAPI"; import { createElement, ReactElement } from "react"; -import { SortComponent } from "./components/SortComponent"; import { DropdownSortPreviewProps } from "../typings/DropdownSortProps"; -import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; +import { SortComponent } from "./components/SortComponent"; + +const DropdownPreview = withSortAPI(SortComponent); export function preview(props: DropdownSortPreviewProps): ReactElement { return ( - ().current ??= `DropdownSort${generateUUID()}`); - const sortProps = useSortControl( - { ...props, emptyOptionCaption: props.emptyOptionCaption?.value }, - props.sortStore - ); +function Container(props: DropdownSortContainerProps & { sortStore: BasicSortStore }): ReactElement { + const id = useConst(() => `DropdownSort${generateUUID()}`); + + const sortProps = useSortSelect({ + emptyOptionCaption: props.emptyOptionCaption?.value, + sortStore: props.sortStore + }); return ( ; -} +export const DropdownSort = withSortAPI(withLinkedSortStore(observer(Container))); diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.xml b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.xml index 09af998512..9dbb977d8a 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.xml +++ b/packages/pluggableWidgets/dropdown-sort-web/src/DropdownSort.xml @@ -8,6 +8,36 @@ + + Datasource to sort + + + + Attributes + Select the attributes that the end-user may use for sorting + + + + Attribute + + + + + + + + + + + + + + Caption + + + + + Empty option caption diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/components/SortComponent.tsx b/packages/pluggableWidgets/dropdown-sort-web/src/components/SortComponent.tsx index 723c659a9c..e6691c0167 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/components/SortComponent.tsx +++ b/packages/pluggableWidgets/dropdown-sort-web/src/components/SortComponent.tsx @@ -4,23 +4,21 @@ import classNames from "classnames"; import { createElement, CSSProperties, ReactElement, useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; -export interface SortOption { - caption: string; - value: string | null; -} - interface SortComponentProps { className?: string; placeholder?: string; id?: string; - options: SortOption[]; + options: Array<{ + caption: string; + value: string; + }>; value: string | null; direction: Dir; tabIndex?: number; screenReaderButtonCaption?: string; screenReaderInputCaption?: string; styles?: CSSProperties; - onSelect?: (option: SortOption) => void; + onSelect?: (value: string) => void; onDirectionClick?: () => void; } @@ -33,8 +31,8 @@ export function SortComponent(props: SortComponentProps): ReactElement { const position = usePositionObserver(componentRef.current, show); const onClick = useCallback( - (option: SortOption) => { - onSelect?.(option); + (option: { value: string }) => { + onSelect?.(option.value); setShow(false); }, [onSelect] @@ -92,7 +90,9 @@ export function SortComponent(props: SortComponentProps): ReactElement { const containerClick = useCallback(() => { setShow(show => !show); setTimeout(() => { - (optionsRef.current?.querySelector("li.filter-selected") as HTMLElement)?.focus(); + const selectedElement = optionsRef.current?.querySelector("li.filter-selected") as HTMLElement; + const firstElement = optionsRef.current?.querySelector("li") as HTMLElement; + (selectedElement || firstElement)?.focus(); }, 10); }, []); @@ -125,6 +125,7 @@ export function SortComponent(props: SortComponentProps): ReactElement { aria-expanded={show} aria-controls={`${props.id}-dropdown-list`} aria-label={props.screenReaderInputCaption} + onChange={() => {}} /> + ); +} + +export const LoadMore = observer(function LoadMore(props: { children: React.ReactNode }): React.ReactNode { + const { + rootStore: { paging } + } = useGalleryRootScope(); + + if (paging.pagination !== "loadMore") { + return null; + } + + if (!paging.hasMoreItems) { + return null; + } + + return paging.setPage(n => n + 1)}>{props.children}; +}); diff --git a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx new file mode 100644 index 0000000000..7e81ba9d40 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx @@ -0,0 +1,17 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; +import { observer } from "mobx-react-lite"; +import { createElement } from "react"; +import { useGalleryRootScope } from "../helpers/root-context"; + +export const SelectionCounter = observer(function SelectionCounter() { + const { selectionCountStore, itemSelectHelper } = useGalleryRootScope(); + + return ( + + {selectionCountStore.displayCount} |  + + + ); +}); diff --git a/packages/pluggableWidgets/gallery-web/src/components/__tests__/Gallery.spec.tsx b/packages/pluggableWidgets/gallery-web/src/components/__tests__/Gallery.spec.tsx index fb235e8d16..3f6bca60bc 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/__tests__/Gallery.spec.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/__tests__/Gallery.spec.tsx @@ -1,11 +1,11 @@ -import "@testing-library/jest-dom"; import { listAction, listExp, setupIntersectionObserverStub } from "@mendix/widget-plugin-test-utils"; -import { waitFor, render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { render, waitFor } from "@testing-library/react"; +import { ObjectItem } from "mendix"; import { createElement } from "react"; -import { Gallery } from "../Gallery"; import { ItemHelperBuilder } from "../../utils/builders/ItemHelperBuilder"; -import { mockProps, mockItemHelperWithAction, setup } from "../../utils/test-utils"; -import { ObjectItem } from "mendix"; +import { mockItemHelperWithAction, mockProps, setup, withGalleryContext } from "../../utils/test-utils"; +import { Gallery } from "../Gallery"; describe("Gallery", () => { beforeAll(() => { @@ -13,14 +13,14 @@ describe("Gallery", () => { }); describe("DOM Structure", () => { it("renders correctly", () => { - const { asFragment } = render(); + const { asFragment } = render(withGalleryContext()); expect(asFragment()).toMatchSnapshot(); }); it("renders correctly with onclick event", () => { const { asFragment } = render( - + withGalleryContext() ); expect(asFragment()).toMatchSnapshot(); @@ -31,7 +31,7 @@ describe("Gallery", () => { it("runs action on item click", async () => { const execute = jest.fn(); const props = mockProps({ onClick: listAction(mock => ({ ...mock(), execute })) }); - const { user, getAllByRole } = setup(); + const { user, getAllByRole } = setup(withGalleryContext()); const [item] = getAllByRole("listitem"); await user.click(item); @@ -41,7 +41,7 @@ describe("Gallery", () => { it("runs action on Enter|Space press when item is in focus", async () => { const execute = jest.fn(); const props = mockProps({ onClick: listAction(mock => ({ ...mock(), execute })) }); - const { user, getAllByRole } = setup(} />); + const { user, getAllByRole } = setup(withGalleryContext(} />)); const [item] = getAllByRole("listitem"); await user.tab(); @@ -55,19 +55,19 @@ describe("Gallery", () => { describe("with different configurations per platform", () => { it("contains correct classes for desktop", () => { - const { getByRole } = render(); + const { getByRole } = render(withGalleryContext()); const list = getByRole("list"); expect(list).toHaveClass("widget-gallery-lg-12"); }); it("contains correct classes for tablet", () => { - const { getByRole } = render(); + const { getByRole } = render(withGalleryContext()); const list = getByRole("list"); expect(list).toHaveClass("widget-gallery-md-6"); }); it("contains correct classes for phone", () => { - const { getByRole } = render(); + const { getByRole } = render(withGalleryContext()); const list = getByRole("list"); expect(list).toHaveClass("widget-gallery-sm-3"); }); @@ -75,17 +75,19 @@ describe("Gallery", () => { describe("with custom classes", () => { it("contains correct classes in the wrapper", () => { - const { container } = render(); + const { container } = render(withGalleryContext()); expect(container.querySelector(".custom-class")).toBeVisible(); }); it("contains correct classes in the items", () => { const { getAllByRole } = render( - b.withItemClass(listExp(() => "custom-class")))} - /> + withGalleryContext( + b.withItemClass(listExp(() => "custom-class")))} + /> + ) ); const [item] = getAllByRole("listitem"); @@ -96,7 +98,9 @@ describe("Gallery", () => { describe("with pagination", () => { it("renders correctly", () => { const { asFragment } = render( - + withGalleryContext( + + ) ); expect(asFragment()).toMatchSnapshot(); @@ -105,14 +109,16 @@ describe("Gallery", () => { it("triggers correct events on click next button", async () => { const setPage = jest.fn(); const { user, getByLabelText } = setup( - + withGalleryContext( + + ) ); const next = getByLabelText("Go to next page"); @@ -124,11 +130,13 @@ describe("Gallery", () => { describe("with empty option", () => { it("renders correctly", () => { const { asFragment } = render( - renderWrapper(No items found)} - /> + withGalleryContext( + renderWrapper(No items found)} + /> + ) ); expect(asFragment()).toMatchSnapshot(); @@ -138,13 +146,15 @@ describe("Gallery", () => { describe("with accessibility properties", () => { it("renders correctly without items", () => { const { asFragment } = render( - renderWrapper(No items found)} - /> + withGalleryContext( + renderWrapper(No items found)} + /> + ) ); expect(asFragment()).toMatchSnapshot(); @@ -152,14 +162,16 @@ describe("Gallery", () => { it("renders correctly with items", () => { const { asFragment } = render( - `title for '${item.id}'`} - headerTitle="filter title" - emptyMessageTitle="empty message" - emptyPlaceholderRenderer={renderWrapper => renderWrapper(No items found)} - /> + withGalleryContext( + `title for '${item.id}'`} + headerTitle="filter title" + emptyMessageTitle="empty message" + emptyPlaceholderRenderer={renderWrapper => renderWrapper(No items found)} + /> + ) ); expect(asFragment()).toMatchSnapshot(); @@ -169,7 +181,7 @@ describe("Gallery", () => { describe("without filters", () => { it("renders structure without header container", () => { const props = { ...mockProps(), showHeader: false, header: undefined }; - const { asFragment } = render(); + const { asFragment } = render(withGalleryContext()); expect(asFragment()).toMatchSnapshot(); }); diff --git a/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap b/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap index 45f1262440..293aded5c8 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap +++ b/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap @@ -6,6 +6,9 @@ exports[`Gallery DOM Structure renders correctly 1`] = ` class="widget-gallery my-gallery" data-focusindex="0" > +