diff --git a/automation/utils/bin/rui-oss-clearance.ts b/automation/utils/bin/rui-oss-clearance.ts new file mode 100755 index 0000000000..834c814741 --- /dev/null +++ b/automation/utils/bin/rui-oss-clearance.ts @@ -0,0 +1,307 @@ +#!/usr/bin/env ts-node-script + +import { gh, GitHubDraftRelease, GitHubReleaseAsset } from "../src/github"; +import { basename, join } from "path"; +import { prompt } from "enquirer"; +import chalk from "chalk"; +import { createReadStream } from "node:fs"; +import * as crypto from "crypto"; +import { pipeline } from "stream/promises"; +import { homedir } from "node:os"; +import { + createSBomGeneratorFolderStructure, + findAllReadmeOssLocally, + generateSBomArtifactsInFolder, + getRecommendedReadmeOss, + includeReadmeOssIntoMpk +} from "../src/oss-clearance"; + +// ============================================================================ +// Constants +// ============================================================================ + +const SBOM_GENERATOR_JAR = join(homedir(), "SBOM_Generator.jar"); + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function printHeader(title: string): void { + console.log("\n" + chalk.bold.cyan("═".repeat(60))); + console.log(chalk.bold.cyan(` ${title}`)); + console.log(chalk.bold.cyan("═".repeat(60)) + "\n"); +} + +function printStep(step: number, total: number, message: string): void { + console.log(chalk.bold.blue(`\n[${step}/${total}]`) + chalk.white(` ${message}`)); +} + +function printSuccess(message: string): void { + console.log(chalk.green(`✅ ${message}`)); +} + +function printError(message: string): void { + console.log(chalk.red(`❌ ${message}`)); +} + +function printWarning(message: string): void { + console.log(chalk.yellow(`⚠️ ${message}`)); +} + +function printInfo(message: string): void { + console.log(chalk.cyan(`ℹ️ ${message}`)); +} + +function printProgress(message: string): void { + console.log(chalk.gray(` → ${message}`)); +} + +// ============================================================================ +// Core Functions +// ============================================================================ + +async function verifyGitHubAuth(): Promise { + printStep(1, 5, "Verifying GitHub authentication..."); + + try { + await gh.ensureAuth(); + printSuccess("GitHub authentication verified"); + } catch (error) { + printError(`GitHub authentication failed: ${(error as Error).message}`); + console.log(chalk.yellow("\n💡 Setup Instructions:\n")); + console.log(chalk.white("1. Install GitHub CLI:")); + console.log(chalk.cyan(" • Download: https://cli.github.com/")); + console.log(chalk.cyan(" • Or via brew: brew install gh\n")); + console.log(chalk.white("2. Authenticate (choose one option):")); + console.log(chalk.cyan(" • Option A: export GITHUB_TOKEN=your_token_here")); + console.log(chalk.cyan(" • Option B: export GH_PAT=your_token_here")); + console.log(chalk.cyan(" • Option C: gh auth login\n")); + console.log(chalk.white("3. For A and B get your token at:")); + console.log(chalk.cyan(" https://github.com/settings/tokens\n")); + throw new Error("GitHub authentication required"); + } +} + +async function selectRelease(): Promise { + printStep(2, 5, "Fetching draft releases..."); + + const releases = await gh.getDraftReleases(); + printSuccess(`Found ${releases.length} draft release${releases.length !== 1 ? "s" : ""}`); + + if (releases.length === 0) { + printWarning( + "No draft releases found. Please create a draft release before trying again using `prepare-release` tool" + ); + throw new Error("No draft releases found"); + } + + console.log(); // spacing + const { tag_name } = await prompt<{ tag_name: string }>({ + type: "select", + name: "tag_name", + message: "Select a release to process:", + choices: releases.map(r => ({ + name: r.tag_name, + message: `${r.name} ${chalk.gray(`(${r.tag_name})`)}` + })) + }); + + const release = releases.find(r => r.tag_name === tag_name); + if (!release) { + throw new Error(`Release not found: ${tag_name}`); + } + + printInfo(`Selected release: ${chalk.bold(release.name)}`); + return release; +} + +async function findAndValidateMpkAsset(release: GitHubDraftRelease): Promise { + printStep(3, 5, "Locating MPK asset..."); + + const mpkAsset = release.assets.find(asset => asset.name.endsWith(".mpk")); + + if (!mpkAsset) { + printError("No MPK asset found in release"); + printInfo(`Available assets: ${release.assets.map(a => a.name).join(", ")}`); + throw new Error("MPK asset not found"); + } + + printSuccess(`Found MPK asset: ${chalk.bold(mpkAsset.name)}`); + printInfo(`Asset ID: ${mpkAsset.id}`); + return mpkAsset; +} + +async function downloadAndVerifyAsset(mpkAsset: GitHubReleaseAsset, downloadPath: string): Promise { + printStep(4, 5, "Downloading and verifying MPK asset..."); + + printProgress(`Downloading to: ${downloadPath}`); + await gh.downloadReleaseAsset(mpkAsset.id, downloadPath); + printSuccess("Download completed"); + + printProgress("Computing SHA-256 hash..."); + const fileHash = await computeHash(downloadPath); + printInfo(`Computed hash: ${fileHash}`); + + const expectedDigest = mpkAsset.digest.replace("sha256:", ""); + if (fileHash !== expectedDigest) { + printError("Hash mismatch detected!"); + printInfo(`Expected: ${expectedDigest}`); + printInfo(`Got: ${fileHash}`); + throw new Error("Asset integrity verification failed"); + } + + printSuccess("Hash verification passed"); + return fileHash; +} + +async function runSbomGenerator(tmpFolder: string, releaseName: string, fileHash: string): Promise { + printStep(5, 5, "Running SBOM Generator..."); + + printProgress("Generating OSS Clearance artifacts..."); + + const finalName = `${releaseName} [${fileHash}].zip`; + const finalPath = join(homedir(), "Downloads", finalName); + + await generateSBomArtifactsInFolder(tmpFolder, SBOM_GENERATOR_JAR, releaseName, finalPath); + printSuccess("Completed."); + + return finalPath; +} + +async function computeHash(filepath: string): Promise { + const input = createReadStream(filepath); + const hash = crypto.createHash("sha256"); + await pipeline(input, hash); + return hash.digest("hex"); +} + +// ============================================================================ +// Command Handlers +// ============================================================================ + +async function handlePrepareCommand(): Promise { + printHeader("OSS Clearance Artifacts Preparation"); + + try { + // Step 1: Verify authentication + await verifyGitHubAuth(); + + // Step 2: Select release + const release = await selectRelease(); + + // Step 3: Find MPK asset + const mpkAsset = await findAndValidateMpkAsset(release); + + // Prepare folder structure + const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(release.name); + printInfo(`Working directory: ${tmpFolder}`); + + // Step 4: Download and verify + const fileHash = await downloadAndVerifyAsset(mpkAsset, downloadPath); + + // Step 5: Run SBOM Generator + const finalPath = await runSbomGenerator(tmpFolder, release.name, fileHash); + + console.log(chalk.bold.green(`\n🎉 Success! Output file:`)); + console.log(chalk.cyan(` ${finalPath}\n`)); + } catch (error) { + console.log("\n" + chalk.bold.red("═".repeat(60))); + printError(`Process failed: ${(error as Error).message}`); + console.log(chalk.bold.red("═".repeat(60)) + "\n"); + process.exit(1); + } +} + +async function handleIncludeCommand(): Promise { + printHeader("OSS Clearance Readme Include"); + + try { + // TODO: Implement include command logic + // Step 1: Verify authentication + await verifyGitHubAuth(); + + // Step 2: Select release + const release = await selectRelease(); + + // Step 3: Find MPK asset + const mpkAsset = await findAndValidateMpkAsset(release); + + // Step 4: Find and select OSS Readme + const readmes = findAllReadmeOssLocally(); + const recommendedReadmeOss = getRecommendedReadmeOss( + release.name.split(" ")[0], + release.name.split(" ")[1], + readmes + ); + + let readmeToInclude: string; + + if (!recommendedReadmeOss) { + const { selectedReadme } = await prompt<{ selectedReadme: string }>({ + type: "select", + name: "selectedReadme", + message: "Select a release to process:", + choices: readmes.map(r => ({ + name: r, + message: basename(r) + })) + }); + + readmeToInclude = selectedReadme; + } else { + readmeToInclude = recommendedReadmeOss; + } + + printInfo(`Readme to include: ${readmeToInclude}`); + + // Step 7: Upload updated asses to the draft release + const newAsset = await gh.uploadReleaseAsset(release.id, readmeToInclude, basename(readmeToInclude)); + console.log(`Successfully uploaded asset ${newAsset.name} (ID: ${newAsset.id})`); + + console.log(release.id); + } catch (error) { + console.log("\n" + chalk.bold.red("═".repeat(60))); + printError(`Process failed: ${(error as Error).message}`); + console.log(chalk.bold.red("═".repeat(60)) + "\n"); + process.exit(1); + } +} + +// ============================================================================ +// Main Function +// ============================================================================ + +async function main(): Promise { + const command = process.argv[2]; + + switch (command) { + case "prepare": + await handlePrepareCommand(); + break; + case "include": + await handleIncludeCommand(); + break; + default: + printError(command ? `Unknown command: ${command}` : "No command specified"); + console.log(chalk.white("\nUsage:")); + console.log( + chalk.cyan(" rui-oss-clearance.ts prepare ") + + chalk.gray("- Prepare OSS clearance artifact from draft release") + ); + console.log( + chalk.cyan(" rui-oss-clearance.ts include ") + + chalk.gray("- Include OSS Readme file into a draft release") + ); + console.log(); + process.exit(1); + } +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +main().catch(e => { + console.error(chalk.red("\n💥 Unexpected error:"), e); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-prepare-release.ts b/automation/utils/bin/rui-prepare-release.ts index 253fe6a1a2..12a00b4ec6 100755 --- a/automation/utils/bin/rui-prepare-release.ts +++ b/automation/utils/bin/rui-prepare-release.ts @@ -353,15 +353,13 @@ async function createReleaseBranch(packageName: string, version: string): Promis } async function initializeJiraClient(): Promise { - const projectKey = process.env.JIRA_PROJECT_KEY; - const baseUrl = process.env.JIRA_BASE_URL; + const projectKey = process.env.JIRA_PROJECT_KEY ?? "WC"; + const baseUrl = process.env.JIRA_BASE_URL ?? "https://mendix.atlassian.net"; 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"); diff --git a/automation/utils/package.json b/automation/utils/package.json index 483509d4d9..480fe2dabe 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -30,6 +30,7 @@ "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/", + "oss-clearance": "ts-node bin/rui-oss-clearance.ts", "prepare": "pnpm run compile:parser:widget && pnpm run compile:parser:module && tsc", "prepare-release": "ts-node bin/rui-prepare-release.ts", "start": "tsc --watch", diff --git a/automation/utils/src/changelog.ts b/automation/utils/src/changelog.ts index 93abe42eab..3f323b48d7 100644 --- a/automation/utils/src/changelog.ts +++ b/automation/utils/src/changelog.ts @@ -1,8 +1,6 @@ import { gh } from "./github"; import { PublishedInfo } from "./package-info"; import { exec, popd, pushd } from "./shell"; -import { findOssReadme } from "./oss-readme"; -import { join } from "path"; export async function updateChangelogsAndCreatePR( info: PublishedInfo, @@ -53,13 +51,6 @@ export async function updateChangelogsAndCreatePR( 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(); diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index ef5f7c92d0..6ad65d1fad 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -1,8 +1,30 @@ -import { mkdtemp, writeFile } from "fs/promises"; +import { mkdtemp, readFile, writeFile } from "fs/promises"; +import { createWriteStream } from "fs"; import { join } from "path"; +import { pipeline } from "stream/promises"; +import nodefetch from "node-fetch"; import { fetch } from "./fetch"; import { exec } from "./shell"; +export interface GitHubReleaseAsset { + id: string; + name: string; + browser_download_url: string; + size: number; + content_type: string; + digest: string; +} + +export interface GitHubDraftRelease { + id: string; + tag_name: string; + name: string; + draft: boolean; + created_at: string; + published_at: string | null; + assets: GitHubReleaseAsset[]; +} + interface GitHubReleaseInfo { title: string; tag: string; @@ -29,12 +51,13 @@ interface GitHubPRInfo { export class GitHub { authSet = false; tmpPrefix = "gh-"; + authToken: string = ""; + owner = "mendix"; + repo = "web-widgets"; async ensureAuth(): Promise { if (!this.authSet) { - if (process.env.GITHUB_TOKEN) { - // when using GITHUB_TOKEN, gh will automatically use it - } else if (process.env.GH_PAT) { + 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 @@ -53,8 +76,10 @@ export class GitHub { try { // Try to run 'gh auth status' to check if authenticated await exec("gh auth status", { stdio: "pipe" }); + const { stdout: token } = await exec(`gh auth token`, { stdio: "pipe" }); + this.authToken = token.trim(); return true; - } catch (error) { + } catch (_error: unknown) { // If the command fails, the user is not authenticated return false; } @@ -107,7 +132,7 @@ export class GitHub { get ghAPIHeaders(): Record { return { "X-GitHub-Api-Version": "2022-11-28", - Authorization: `Bearer ${process.env.GH_PAT}` + Authorization: `Bearer ${this.authToken || process.env.GH_PAT}` }; } @@ -117,7 +142,7 @@ export class GitHub { const release = (await fetch<{ id: string }>( "GET", - `https://api.github.com/repos/mendix/web-widgets/releases/tags/${releaseTag}`, + `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`, undefined, { ...this.ghAPIHeaders } )) ?? []; @@ -148,7 +173,7 @@ export class GitHub { name: string; browser_download_url: string; }> - >("GET", `https://api.github.com/repos/mendix/web-widgets/releases/${releaseId}/assets`, undefined, { + >("GET", `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets`, undefined, { ...this.ghAPIHeaders }); } @@ -165,6 +190,53 @@ export class GitHub { return downloadUrl; } + async getDraftReleases(): Promise { + const releases = await fetch( + "GET", + `https://api.github.com/repos/${this.owner}/${this.repo}/releases`, + undefined, + { + ...this.ghAPIHeaders + } + ); + + // Filter only draft releases + return releases.filter(release => release.draft); + } + + async downloadReleaseAsset(assetId: string, destinationPath: string): Promise { + await this.ensureAuth(); + + const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/assets/${assetId}`; + + try { + const response = await nodefetch(url, { + method: "GET", + headers: { + Accept: "application/octet-stream", + ...this.ghAPIHeaders + }, + redirect: "follow" + }); + + if (!response.ok) { + throw new Error(`Failed to download asset ${assetId}: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error(`No response body received for asset ${assetId}`); + } + + // Stream the response body to the file + const fileStream = createWriteStream(destinationPath); + await pipeline(response.body, fileStream); + } catch (error) { + throw new Error( + `Failed to download release asset ${assetId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + async createReleaseNotesFile(releaseNotesText: string): Promise { const filePath = await this.createTempFile(); await writeFile(filePath, releaseNotesText); @@ -181,14 +253,14 @@ export class GitHub { }): Promise { await this.ensureAuth(); - const { workflowId, ref, inputs, owner = "mendix", repo = "web-widgets" } = params; + const { workflowId, ref, inputs } = params; // Convert inputs object to CLI parameters const inputParams = Object.entries(inputs) .map(([key, value]) => `-f ${key}=${value}`) .join(" "); - const repoParam = `${owner}/${repo}`; + const repoParam = `${this.owner}/${this.repo}`; const command = [`gh workflow run`, `"${workflowId}"`, `--ref "${ref}"`, inputParams, `-R "${repoParam}"`] .filter(Boolean) @@ -211,6 +283,68 @@ export class GitHub { } }); } + + /** + * Delete a release asset by ID + */ + async deleteReleaseAsset(assetId: string): Promise { + await this.ensureAuth(); + + const response = await nodefetch( + `https://api.github.com/repos/${this.owner}/${this.repo}/releases/assets/${assetId}`, + { + method: "DELETE", + headers: this.ghAPIHeaders + } + ); + + if (!response.ok) { + throw new Error(`Failed to delete asset ${assetId}: ${response.status} ${response.statusText}`); + } + } + + /** + * Upload a new asset to a release + */ + async uploadReleaseAsset(releaseId: string, filePath: string, assetName: string): Promise { + await this.ensureAuth(); + + // Get release info to get upload URL + const release = await fetch<{ upload_url: string }>( + "GET", + `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}`, + undefined, + this.ghAPIHeaders + ); + + // The upload_url comes with {?name,label} template, we need to replace it + const uploadUrl = release.upload_url.replace(/\{[^}]+}/g, "") + `?name=${encodeURIComponent(assetName)}`; + + // Read the file + const fileBuffer = await readFile(filePath); + + // Upload the file + const response = await nodefetch(uploadUrl, { + method: "POST", + headers: { + ...this.ghAPIHeaders, + "Content-Type": "application/octet-stream", + "Content-Length": fileBuffer.length.toString() + }, + body: fileBuffer + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to upload asset ${assetName}: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + const asset = (await response.json()) as GitHubReleaseAsset; + + return asset; + } } export const gh = new GitHub(); diff --git a/automation/utils/src/oss-clearance.ts b/automation/utils/src/oss-clearance.ts new file mode 100644 index 0000000000..f2cfb25b87 --- /dev/null +++ b/automation/utils/src/oss-clearance.ts @@ -0,0 +1,90 @@ +import { globSync } from "glob"; +import { basename, join, parse } from "path"; +import { homedir, tmpdir } from "node:os"; +import { mkdtemp, stat } from "node:fs/promises"; +import { chmod, cp, exec, mkdir, mv, rm, unzip, zip } from "./shell"; +import chalk from "chalk"; + +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]; +} + +export function findAllReadmeOssLocally(): string[] { + const readmeossPattern = join("**", `*__*__READMEOSS_*.html`); + const path1 = join(homedir(), "Downloads"); + const path2 = join(homedir(), "Documents"); + + const matchingFiles1 = globSync(readmeossPattern, { cwd: path1, absolute: true, ignore: "**/.*/**" }); + const matchingFiles2 = globSync(readmeossPattern, { cwd: path2, absolute: true, ignore: "**/.*/**" }); + + return matchingFiles1.concat(matchingFiles2); +} + +export function getRecommendedReadmeOss(name: string, version: string, availableReadmes: string[]): string | undefined { + const fileNames = availableReadmes.map(r => basename(r)); + + return fileNames.find(r => r.includes(name) && r.includes(version)); +} + +export async function createSBomGeneratorFolderStructure( + assetNameAndVersion: string +): Promise<[folder: string, assetPath: string]> { + const tmpFolder = await mkdtemp(join(tmpdir(), "tmp_OSS_Clearance_Artifacts_")); + const artifactsFolder = join(tmpFolder, "SBOM_GENERATOR", assetNameAndVersion); + await mkdir("-p", artifactsFolder); + return [tmpFolder, join(artifactsFolder, `${assetNameAndVersion}.mpk`)]; +} + +export async function generateSBomArtifactsInFolder( + tmpFolder: string, + generatorBinaryPath: string, + expectedName: string, + finalPath: string +): Promise { + // run generator + await exec(`java -jar ${generatorBinaryPath} SBOM_GENERATOR unzip`, { cwd: tmpFolder }); + await exec(`java -jar ${generatorBinaryPath} SBOM_GENERATOR scan`, { cwd: tmpFolder }); + + // check results + const resultsFolder = join(tmpFolder, "CCA_JSON"); + const assetsFolder = join(resultsFolder, expectedName); + const assets = await stat(assetsFolder); + if (!assets.isDirectory()) { + throw new Error("Can't find assets folder"); + } + + // archive results + const archiveName = `${expectedName}.zip`; + await zip(resultsFolder, archiveName); + const ossArtifactZip = join(resultsFolder, archiveName); + + // move to final destination + await mv(ossArtifactZip, finalPath); + + // removing tmp folder + await rm("-rf", tmpFolder); +} + +export async function includeReadmeOssIntoMpk(readmeOssPath: string, mpkPath: string): Promise { + const mpkEntry = parse(mpkPath); + const unzipTarget = join(mpkEntry.dir, "tmp"); + + // unzip + rm("-rf", unzipTarget); + await unzip(mpkPath, unzipTarget); + chmod("-R", "a+rw", unzipTarget); + + // Copy the READMEOSS file to the target directory + cp(readmeOssPath, unzipTarget); + + // zip it back + await zip(unzipTarget, mpkPath); + rm("-rf", unzipTarget); +} diff --git a/automation/utils/src/oss-readme.ts b/automation/utils/src/oss-readme.ts deleted file mode 100644 index 1fb73035eb..0000000000 --- a/automation/utils/src/oss-readme.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/steps.ts b/automation/utils/src/steps.ts index 4522396755..baf9dfba6a 100644 --- a/automation/utils/src/steps.ts +++ b/automation/utils/src/steps.ts @@ -14,7 +14,7 @@ import { ModuleInfo, PackageInfo, WidgetInfo } from "./package-info"; import { addFilesToPackageXml, PackageType } from "./package-xml"; import { chmod, cp, ensureFileExists, exec, find, mkdir, popd, pushd, rm, unzip, zip } from "./shell"; import chalk from "chalk"; -import { findOssReadme } from "./oss-readme"; +import { findOssReadme } from "./oss-clearance"; type Step = (params: { info: Info; config: Config }) => Promise; diff --git a/package.json b/package.json index b79f4793f0..f9cc2c134d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "turbo run build", "changelog": "pnpm --filter @mendix/automation-utils run changelog", + "oss-clearance": "pnpm --filter @mendix/automation-utils run oss-clearance", "create-gh-release": "turbo run create-gh-release --concurrency 1", "create-translation": "turbo run create-translation", "postinstall": "turbo run agent-rules", @@ -49,6 +50,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.1", "@mendix/pluggable-widgets-tools": "10.21.2", + "@testing-library/react": ">=15.0.6", "@types/big.js": "^6.2.2", "@types/node": "~22.14.0", "@types/react": ">=18.2.36", diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index f6f3a80d50..0fd98997a9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -1,48 +1,38 @@ import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; -import { useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; -import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { ContainerProvider } from "brandi-react"; import { observer } from "mobx-react-lite"; -import { ReactElement, ReactNode, useCallback, useMemo } from "react"; +import { ReactElement } from "react"; import { DatagridContainerProps } from "../typings/DatagridProps"; -import { Cell } from "./components/Cell"; import { Widget } from "./components/Widget"; -import { WidgetHeaderContext } from "./components/WidgetHeaderContext"; import { useDataExport } from "./features/data-export/useDataExport"; import { useCellEventsController } from "./features/row-interaction/CellEventsController"; import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; import { LegacyContext } from "./helpers/root-context"; -import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { useDataGridJSActions } from "./helpers/useDataGridJSActions"; import { useColumnsStore, + useDatagridConfig, useExportProgressService, - useLoaderViewModel, useMainGate, - usePaginationService + useSelectActions, + useSelectionHelper } from "./model/hooks/injection-hooks"; import { useDatagridContainer } from "./model/hooks/useDatagridContainer"; const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { + const config = useDatagridConfig(); const gate = useMainGate(); const columnsStore = useColumnsStore(); - const paginationService = usePaginationService(); const exportProgress = useExportProgressService(); - const loaderVM = useLoaderViewModel(); const items = gate.props.datasource.items ?? []; const [abortExport] = useDataExport(props, columnsStore, exportProgress); - const selectionHelper = useSelectionHelper( - gate.props.itemSelection, - gate.props.datasource, - props.onSelectionChange, - props.keepSelection ? "always keep" : "always clear" - ); + const selectionHelper = useSelectionHelper(); - const selectActionHelper = useSelectActionHelper(props, selectionHelper); + const selectActionHelper = useSelectActions(); const clickActionHelper = useClickActionHelper({ onClickTrigger: props.onClickTrigger, @@ -51,7 +41,7 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { useDataGridJSActions(selectActionHelper); - const visibleColumnsCount = selectActionHelper.showCheckboxColumn + const visibleColumnsCount = config.checkboxColumnEnabled ? columnsStore.visibleColumns.length + 1 : columnsStore.visibleColumns.length; @@ -75,66 +65,7 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { focusController })} > - ReactElement) => - props.showEmptyPlaceholder === "custom" ? renderWrapper(props.emptyPlaceholder) :
, - [props.emptyPlaceholder, props.showEmptyPlaceholder] - )} - filterRenderer={useCallback( - (renderWrapper, columnIndex) => { - const columnFilter = columnsStore.columnFilters[columnIndex]; - return renderWrapper(columnFilter.renderFilterWidgets()); - }, - [columnsStore.columnFilters] - )} - headerTitle={props.filterSectionTitle?.value} - headerContent={ - props.filtersPlaceholder && ( - - {props.filtersPlaceholder} - - ) - } - hasMoreItems={props.datasource.hasMoreItems ?? false} - headerWrapperRenderer={useCallback((_columnIndex: number, header: ReactElement) => header, [])} - id={useMemo(() => `DataGrid${generateUUID()}`, [])} - numberOfItems={props.datasource.totalCount} - onExportCancel={abortExport} - page={paginationService.currentPage} - pageSize={props.pageSize} - paginationType={props.pagination} - loadMoreButtonCaption={props.loadMoreButtonCaption?.value} - paging={paginationService.showPagination} - pagingPosition={props.pagingPosition} - showPagingButtons={props.showPagingButtons} - rowClass={useCallback((value: any) => props.rowClass?.get(value)?.value ?? "", [props.rowClass])} - setPage={paginationService.setPage} - styles={props.style} - exporting={exportProgress.inProgress} - processedRows={exportProgress.loaded} - visibleColumns={columnsStore.visibleColumns} - availableColumns={columnsStore.availableColumns} - setIsResizing={(status: boolean) => columnsStore.setIsResizing(status)} - columnsSwap={(moved, [target, placement]) => columnsStore.swapColumns(moved, [target, placement])} - selectActionHelper={selectActionHelper} - cellEventsController={cellEventsController} - checkboxEventsController={checkboxEventsController} - focusController={focusController} - isFirstLoad={loaderVM.isFirstLoad} - isFetchingNextBatch={loaderVM.isFetchingNextBatch} - showRefreshIndicator={loaderVM.showRefreshIndicator} - loadingType={props.loadingType} - columnsLoading={!columnsStore.loaded} - /> + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx new file mode 100644 index 0000000000..1697f15102 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -0,0 +1,128 @@ +import classNames from "classnames"; +import { HTMLAttributes, KeyboardEvent, ReactElement, ReactNode, useMemo } from "react"; +import { FaArrowsAltV } from "./icons/FaArrowsAltV"; +import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; +import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; + +import ColumnHeader from "./ColumnHeader"; + +import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDragDrop } from "../model/hooks/injection-hooks"; +import { GridColumn } from "../typings/GridColumn"; +import { ColumnResizerProps } from "./ColumnResizer"; +import { ColumnHeaderViewModel } from "../features/column/ColumnHeader.viewModel"; +import { observer } from "mobx-react-lite"; + +export interface ColumnContainerProps { + isLast?: boolean; + resizer: ReactElement; +} + +export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { + const { columnsFilterable, id: gridId } = useDatagridConfig(); + const columnsStore = useColumnsStore(); + const column = useColumn(); + const { canDrag, canSort } = column; + + const headerDragDropStore = useHeaderDragDrop(); + const columnHeaderVM = useMemo( + () => + new ColumnHeaderViewModel({ + dndStore: headerDragDropStore, + columnsStore, + columnsDraggable: canDrag + }), + [headerDragDropStore, columnsStore, canDrag] + ); + const draggableProps = columnHeaderVM.draggableProps; + const dropTarget = columnHeaderVM.dropTarget; + const isDragging = columnHeaderVM.dragging; + + const sortProps = canSort ? getSortProps(column) : null; + const caption = column.header.trim(); + + return ( +
column.setHeaderElementRef(ref)} + data-column-id={column.columnId} + onDrop={draggableProps.onDrop} + onDragEnter={draggableProps.onDragEnter} + onDragOver={draggableProps.onDragOver} + > +
+ + {canSort ? : null} + + {columnsFilterable && ( +
+ {columnsStore.columnFilters[column.columnIndex]?.renderFilterWidgets()} +
+ )} +
+ {column.canResize ? props.resizer : null} +
+ ); +}); + +function SortIcon(): ReactNode { + const column = useColumn(); + switch (column.sortDir) { + case "asc": + return ; + case "desc": + return ; + default: + return ; + } +} + +function getAriaSort(canSort: boolean, column: GridColumn): "ascending" | "descending" | "none" | undefined { + if (!canSort) { + return undefined; + } + + switch (column.sortDir) { + case "asc": + return "ascending"; + case "desc": + return "descending"; + default: + return "none"; + } +} + +function getSortProps(column: GridColumn): HTMLAttributes { + return { + onClick: () => { + column.toggleSort(); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + column.toggleSort(); + } + }, + role: "button", + tabIndex: 0 + }; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx new file mode 100644 index 0000000000..a27a18e7af --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -0,0 +1,29 @@ +import classNames from "classnames"; +import { HTMLAttributes, ReactElement, ReactNode } from "react"; + +export interface ColumnHeaderProps { + children?: ReactNode; + sortProps?: HTMLAttributes | null; + canSort: boolean; + caption: string; + isDragging?: [string | undefined, string, string | undefined] | undefined; + columnAlignment?: "left" | "center" | "right"; +} + +export default function ColumnHeader(props: ColumnHeaderProps): ReactElement { + return ( +
+ {props.caption.length > 0 ? props.caption : "\u00a0"} + {props.children} +
+ ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnProvider.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnProvider.tsx new file mode 100644 index 0000000000..cc4003d69b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnProvider.tsx @@ -0,0 +1,17 @@ +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { Container } from "brandi"; +import { ContainerProvider } from "brandi-react"; +import { PropsWithChildren, ReactNode } from "react"; +import { CORE_TOKENS as CORE } from "../model/tokens"; +import { GridColumn } from "../typings/GridColumn"; + +/** Provider to bind & provider column store for children at runtime. */ +export function ColumnProvider(props: PropsWithChildren<{ column: GridColumn }>): ReactNode { + const ct = useConst(() => { + const container = new Container(); + container.bind(CORE.column).toConstant(props.column); + return container; + }); + + return {props.children}; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx index 90dc7f7449..bcbc4bcfe9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx @@ -1,24 +1,18 @@ import { useEventCallback } from "@mendix/widget-plugin-hooks/useEventCallback"; import { MouseEvent, ReactElement, TouchEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useColumn, useColumnsStore } from "../model/hooks/injection-hooks"; export interface ColumnResizerProps { minWidth?: number; - setColumnWidth: (width: number) => void; - onResizeEnds?: () => void; - onResizeStart?: () => void; } -export function ColumnResizer({ - minWidth = 50, - setColumnWidth, - onResizeEnds, - onResizeStart -}: ColumnResizerProps): ReactElement { +export function ColumnResizer({ minWidth = 50 }: ColumnResizerProps): ReactElement { + const column = useColumn(); + const columnsStore = useColumnsStore(); const [isResizing, setIsResizing] = useState(false); const [startPosition, setStartPosition] = useState(0); const [currentWidth, setCurrentWidth] = useState(0); const resizerReference = useRef(null); - const onStart = useEventCallback(onResizeStart); const onStartDrag = useCallback( (e: TouchEvent & MouseEvent): void => { @@ -26,12 +20,12 @@ export function ColumnResizer({ setStartPosition(mouseX); setIsResizing(true); if (resizerReference.current) { - const column = resizerReference.current.parentElement!; - setCurrentWidth(column.offsetWidth); + const columnElement = resizerReference.current.parentElement!; + setCurrentWidth(columnElement.offsetWidth); } - onStart(); + columnsStore.setIsResizing(true); }, - [onStart] + [columnsStore] ); const onEndDrag = useCallback((): void => { if (!isResizing) { @@ -39,9 +33,9 @@ export function ColumnResizer({ } setIsResizing(false); setCurrentWidth(0); - onResizeEnds?.(); - }, [onResizeEnds, isResizing]); - const setColumnWidthStable = useEventCallback(setColumnWidth); + columnsStore.setIsResizing(false); + }, [columnsStore, isResizing]); + const setColumnWidthStable = useEventCallback((width: number) => column.setSize(width)); const onMouseMove = useCallback( (e: TouchEvent & MouseEvent & Event): void => { if (!isResizing) { diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Cell.tsx b/packages/pluggableWidgets/datagrid-web/src/components/DataCell.tsx similarity index 73% rename from packages/pluggableWidgets/datagrid-web/src/components/Cell.tsx rename to packages/pluggableWidgets/datagrid-web/src/components/DataCell.tsx index 653862e79a..70307640f6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Cell.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/DataCell.tsx @@ -1,12 +1,26 @@ import { useFocusTargetProps } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetProps"; +import { ObjectItem } from "mendix"; import { computed } from "mobx"; import { observer } from "mobx-react-lite"; -import { ReactElement, useMemo } from "react"; -import { CellComponentProps } from "../typings/CellComponent"; +import { ReactElement, ReactNode, useMemo } from "react"; +import { EventsController } from "../typings/CellComponent"; import { GridColumn } from "../typings/GridColumn"; import { CellElement } from "./CellElement"; -const component = observer(function Cell(props: CellComponentProps): ReactElement { +interface DataCellProps { + children?: ReactNode; + className?: string; + column: GridColumn; + item: ObjectItem; + key?: string | number; + rowIndex: number; + columnIndex?: number; + clickable?: boolean; + preview?: boolean; + eventsController: EventsController; +} + +export const DataCell = observer(function DataCell(props: DataCellProps): ReactElement { const keyNavProps = useFocusTargetProps({ columnIndex: props.columnIndex ?? -1, rowIndex: props.rowIndex @@ -36,6 +50,3 @@ const component = observer(function Cell(props: CellComponentProps): ); }); - -// Override NamedExoticComponent type -export const Cell = component as (props: CellComponentProps) => ReactElement; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx deleted file mode 100644 index 7ab41f5b7f..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/ExportWidget.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PropsWithChildren, ReactElement } from "react"; -import { PseudoModal } from "./PseudoModal"; -import { ExportAlert, ExportAlertProps } from "./ExportAlert"; - -type ExportWidgetProps = PropsWithChildren< - ExportAlertProps & { - open: boolean; - } ->; - -export function ExportWidget({ open, ...alertProps }: ExportWidgetProps): ReactElement | null { - if (!open) { - return null; - } - - return ( - - - - ); -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx index 96948a3bca..015fb3f539 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx @@ -1,18 +1,18 @@ -import classNames from "classnames"; -import { ComponentPropsWithoutRef, ReactElement } from "react"; - -type P = Omit, "role">; - -export interface GridProps extends P { - className?: string; -} - -export function Grid(props: GridProps): ReactElement { - const { className, style, children, ...rest } = props; +import { observer } from "mobx-react-lite"; +import { PropsWithChildren, ReactElement } from "react"; +import { useDatagridConfig, useGridStyle } from "../model/hooks/injection-hooks"; +export const Grid = observer(function Grid(props: PropsWithChildren): ReactElement { + const config = useDatagridConfig(); + const style = useGridStyle().get(); return ( -
- {children} +
+ {props.children}
); -} +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index b9c3e76bb1..807d830907 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -1,87 +1,107 @@ +import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; import classNames from "classnames"; -import { Fragment, ReactElement, ReactNode } from "react"; -import { LoadingTypeEnum, PaginationEnum } from "../../typings/DatagridProps"; -import { SpinnerLoader } from "./loader/SpinnerLoader"; +import { observer } from "mobx-react-lite"; +import { Fragment, PropsWithChildren, ReactElement, ReactNode, RefObject, UIEventHandler, useCallback } from "react"; +import { + useDatagridConfig, + useItemCount, + useLoaderViewModel, + usePaginationService, + useVisibleColumnsCount +} from "../model/hooks/injection-hooks"; import { RowSkeletonLoader } from "./loader/RowSkeletonLoader"; -import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; - -interface Props { - className?: string; - children?: ReactNode; - loadingType: LoadingTypeEnum; - isFirstLoad: boolean; - isFetchingNextBatch?: boolean; - columnsHidable: boolean; - columnsSize: number; - rowsSize: number; - pageSize: number; - pagination: PaginationEnum; - hasMoreItems: boolean; - setPage?: (update: (page: number) => number) => void; -} - -export function GridBody(props: Props): ReactElement { - const { children, pagination, hasMoreItems, setPage } = props; - - const isInfinite = pagination === "virtualScrolling"; - const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ - hasMoreItems, - isInfinite, - setPage - }); +import { SpinnerLoader } from "./loader/SpinnerLoader"; - const content = (): ReactElement => { - if (props.isFirstLoad) { - return 0 ? props.rowsSize : props.pageSize} />; - } - return ( - - {children} - {props.isFetchingNextBatch && } - - ); - }; +export function GridBody(props: PropsWithChildren): ReactElement { + const { children } = props; + const { bodySize, containerRef, isInfinite, handleScroll } = useBodyScroll(); return (
0 ? { maxHeight: `${bodySize}px` } : {}} role="rowgroup" ref={containerRef} - onScroll={isInfinite ? trackScrolling : undefined} + onScroll={handleScroll} > - {content()} + {children}
); } -interface LoaderProps { - loadingType: LoadingTypeEnum; - columnsHidable: boolean; - columnsSize: number; - rowsSize: number; - useBorderTop?: boolean; -} +const ContentGuard = observer(function ContentGuard(props: PropsWithChildren): ReactNode { + const loaderVM = useLoaderViewModel(); + const { pageSize } = usePaginationService(); + const config = useDatagridConfig(); + const columnsCount = useVisibleColumnsCount().get(); + const itemCount = useItemCount().get(); + + if (loaderVM.isFirstLoad && config.loadingType === "spinner") { + return ; + } -function Loader(props: LoaderProps): ReactElement { - if (props.loadingType === "spinner") { + if (loaderVM.isFirstLoad) { return ( -
- -
+ 0 ? itemCount : pageSize} + useBorderTop + /> ); } return ( - + + {props.children} + {(() => { + if (loaderVM.isFetchingNextBatch && config.loadingType === "spinner") { + return ; + } + + if (loaderVM.isFetchingNextBatch) { + return ( + + ); + } + + return null; + })()} + ); +}); + +function useBodyScroll(): { + handleScroll: UIEventHandler | undefined; + bodySize: number; + containerRef: RefObject; + isInfinite: boolean; +} { + const paging = usePaginationService(); + const setPage = useCallback((cb: (n: number) => number) => paging.setPage(cb), [paging]); + + const isInfinite = paging.pagination === "virtualScrolling"; + const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ + hasMoreItems: paging.hasMoreItems, + isInfinite, + setPage + }); + + return { + handleScroll: isInfinite ? trackScrolling : undefined, + bodySize, + containerRef, + isInfinite + }; } + +const Spinner = (): ReactNode => ( +
+ +
+); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx index 5ef8785352..029dfc3bae 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx @@ -1,57 +1,18 @@ -import { ReactElement, ReactNode, useCallback, useState } from "react"; -import { ColumnId, GridColumn } from "../typings/GridColumn"; +import { ReactElement } from "react"; +import { useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; import { CheckboxColumnHeader } from "./CheckboxColumnHeader"; +import { ColumnProvider } from "./ColumnProvider"; import { ColumnResizer } from "./ColumnResizer"; import { ColumnSelector } from "./ColumnSelector"; -import { Header } from "./Header"; +import { ColumnContainer } from "./ColumnContainer"; import { HeaderSkeletonLoader } from "./loader/HeaderSkeletonLoader"; -type GridHeaderProps = { - availableColumns: GridColumn[]; - columns: GridColumn[]; - setIsResizing: (status: boolean) => void; - columnsDraggable: boolean; - columnsFilterable: boolean; - columnsHidable: boolean; - columnsResizable: boolean; - columnsSortable: boolean; - columnsSwap: (source: ColumnId, target: [ColumnId, "after" | "before"]) => void; - filterRenderer: (renderWrapper: (children: ReactNode) => ReactElement, columnIndex: number) => ReactElement; - headerWrapperRenderer: (columnIndex: number, header: ReactElement) => ReactElement; - id: string; - isLoading: boolean; - preview?: boolean; -}; +export function GridHeader(): ReactElement { + const { columnsHidable, id: gridId } = useDatagridConfig(); + const columnsStore = useColumnsStore(); + const columns = columnsStore.visibleColumns; -export function GridHeader({ - availableColumns, - columns, - setIsResizing, - columnsDraggable, - columnsFilterable, - columnsHidable, - columnsResizable, - columnsSortable, - columnsSwap, - filterRenderer, - headerWrapperRenderer, - id, - isLoading, - preview -}: GridHeaderProps): ReactElement { - const [dragOver, setDragOver] = useState<[ColumnId, "before" | "after"] | undefined>(undefined); - const [isDragging, setIsDragging] = useState<[ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined>(); - - const renderFilterWrapper = useCallback( - (children: ReactNode) => ( -
- {children} -
- ), - [isDragging] - ); - - if (isLoading) { + if (!columnsStore.loaded) { return ; } @@ -59,41 +20,16 @@ export function GridHeader({
- {columns.map((column, index) => - headerWrapperRenderer( - index, -
setIsResizing(true)} - onResizeEnds={() => setIsResizing(false)} - setColumnWidth={(width: number) => column.setSize(width)} - /> - } - swapColumns={columnsSwap} - setDropTarget={setDragOver} - setIsDragging={setIsDragging} - sortable={columnsSortable} - /> - ) - )} + {columns.map(column => ( + + } /> + + ))} {columnsHidable && ( )} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx deleted file mode 100644 index 54c35f8c6a..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { - Dispatch, - DragEvent, - DragEventHandler, - HTMLAttributes, - KeyboardEvent, - ReactElement, - ReactNode, - SetStateAction, - useCallback -} from "react"; -import classNames from "classnames"; -import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; -import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; -import { FaArrowsAltV } from "./icons/FaArrowsAltV"; - -import { ColumnResizerProps } from "./ColumnResizer"; -import { ColumnId, GridColumn } from "../typings/GridColumn"; - -export interface HeaderProps { - className?: string; - gridId: string; - column: GridColumn; - sortable: boolean; - resizable: boolean; - filterable: boolean; - hidable: boolean; - draggable: boolean; - filterWidget?: ReactNode; - preview?: boolean; - resizer: ReactElement; - dropTarget?: [ColumnId, "before" | "after"]; - isDragging?: [ColumnId | undefined, ColumnId, ColumnId | undefined]; - setDropTarget: Dispatch>; - setIsDragging: Dispatch>; - swapColumns: (source: ColumnId, target: [ColumnId, "before" | "after"]) => void; -} - -export function Header(props: HeaderProps): ReactElement { - const canSort = props.sortable && props.column.canSort; - const canDrag = props.draggable && (props.column.canDrag ?? false); - const draggableProps = useDraggable( - canDrag, - props.swapColumns, - props.dropTarget, - props.setDropTarget, - props.isDragging, - props.setIsDragging - ); - - const sortIcon = canSort ? getSortIcon(props.column) : null; - const sortProps = canSort ? getSortProps(props.column) : null; - const caption = props.column.header.trim(); - - return ( -
props.column.setHeaderElementRef(ref)} - data-column-id={props.column.columnId} - onDrop={draggableProps.onDrop} - onDragEnter={draggableProps.onDragEnter} - onDragOver={draggableProps.onDragOver} - > -
-
- {caption.length > 0 ? caption : "\u00a0"} - {sortIcon} -
- {props.filterable && props.filterWidget} -
- {props.resizable && props.column.canResize && props.resizer} -
- ); -} - -function useDraggable( - columnsDraggable: boolean, - setColumnOrder: (source: ColumnId, target: [ColumnId, "after" | "before"]) => void, - dropTarget: [ColumnId, "before" | "after"] | undefined, - setDropTarget: Dispatch>, - dragging: [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined, - setDragging: Dispatch> -): { - draggable?: boolean; - onDragStart?: DragEventHandler; - onDragOver?: DragEventHandler; - onDrop?: DragEventHandler; - onDragEnter?: DragEventHandler; - onDragEnd?: DragEventHandler; -} { - const handleDragStart = useCallback( - (e: DragEvent): void => { - const elt = (e.target as HTMLDivElement).closest(".th") as HTMLDivElement; - const columnId = elt.dataset.columnId ?? ""; - - const columnAtTheLeft = (elt.previousElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; - const columnAtTheRight = (elt.nextElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; - - setDragging([columnAtTheLeft, columnId as ColumnId, columnAtTheRight]); - }, - [setDragging] - ); - - const handleDragOver = useCallback( - (e: DragEvent): void => { - if (!dragging) { - return; - } - const columnId = (e.currentTarget as HTMLDivElement).dataset.columnId as ColumnId; - if (!columnId) { - return; - } - e.preventDefault(); - - const [leftSiblingColumnId, draggingColumnId, rightSiblingColumnId] = dragging; - - if (columnId === draggingColumnId) { - // hover on itself place, no highlight - if (dropTarget !== undefined) { - setDropTarget(undefined); - } - return; - } - - let isAfter: boolean; - - if (columnId === leftSiblingColumnId) { - isAfter = false; - } else if (columnId === rightSiblingColumnId) { - isAfter = true; - } else { - // check position in element - const rect = e.currentTarget.getBoundingClientRect(); - isAfter = rect.width / 2 + (dropTarget?.[1] === "after" ? -10 : 10) < e.clientX - rect.left; - } - - const newPosition = isAfter ? "after" : "before"; - - if (columnId !== dropTarget?.[0] || newPosition !== dropTarget?.[1]) { - setDropTarget([columnId, newPosition]); - } - }, - [dragging, dropTarget, setDropTarget] - ); - - const handleDragEnter = useCallback((e: DragEvent): void => { - e.preventDefault(); - }, []); - - const handleDragEnd = useCallback((): void => { - setDragging(undefined); - setDropTarget(undefined); - }, [setDropTarget, setDragging]); - - const handleOnDrop = useCallback( - (_e: DragEvent): void => { - handleDragEnd(); - if (!dragging || !dropTarget) { - return; - } - - setColumnOrder(dragging[1], dropTarget); - }, - [handleDragEnd, setColumnOrder, dragging, dropTarget] - ); - - return columnsDraggable - ? { - draggable: true, - onDragStart: handleDragStart, - onDragOver: handleDragOver, - onDrop: handleOnDrop, - onDragEnter: handleDragEnter, - onDragEnd: handleDragEnd - } - : {}; -} - -function getSortIcon(column: GridColumn): ReactNode { - switch (column.sortDir) { - case "asc": - return ; - case "desc": - return ; - default: - return ; - } -} - -function getAriaSort(canSort: boolean, column: GridColumn): "ascending" | "descending" | "none" | undefined { - if (!canSort) { - return undefined; - } - - switch (column.sortDir) { - case "asc": - return "ascending"; - case "desc": - return "descending"; - default: - return "none"; - } -} - -function getSortProps(column: GridColumn): HTMLAttributes { - return { - onClick: () => { - column.toggleSort(); - }, - onKeyDown: (e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - column.toggleSort(); - } - }, - role: "button", - tabIndex: 0 - }; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx new file mode 100644 index 0000000000..fb8200c6d8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx @@ -0,0 +1,25 @@ +import { Pagination as PaginationComponent } from "@mendix/widget-plugin-grid/components/Pagination"; +import { observer } from "mobx-react-lite"; +import { ReactNode } from "react"; +import { usePaginationService } from "../model/hooks/injection-hooks"; + +export const Pagination = observer(function Pagination(): ReactNode { + const paging = usePaginationService(); + + if (!paging.paginationVisible) return null; + + return ( + paging.setPage(page)} + nextPage={() => paging.setPage(n => n + 1)} + numberOfItems={paging.totalCount} + page={paging.currentPage} + pageSize={paging.pageSize} + showPagingButtons={paging.showPagingButtons} + previousPage={() => paging.setPage(n => n - 1)} + pagination={paging.pagination} + /> + ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/RefreshStatus.tsx b/packages/pluggableWidgets/datagrid-web/src/components/RefreshStatus.tsx new file mode 100644 index 0000000000..aaf0446990 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/RefreshStatus.tsx @@ -0,0 +1,11 @@ +import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator"; +import { ReactNode } from "react"; +import { useLoaderViewModel } from "../model/hooks/injection-hooks"; + +export const RefreshStatus = function RefreshStatus(): ReactNode { + const loaderVM = useLoaderViewModel(); + + if (!loaderVM.showRefreshIndicator) return null; + + return loaderVM.isRefreshing ? : null; +}; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx index c89ed119d4..4d41de7428 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx @@ -1,40 +1,39 @@ +import { SelectActionsService } from "@mendix/widget-plugin-grid/main"; import classNames from "classnames"; import { ObjectItem } from "mendix"; import { ReactElement } from "react"; -import { CellComponent, EventsController } from "../typings/CellComponent"; +import { EventsController } from "../typings/CellComponent"; import { GridColumn } from "../typings/GridColumn"; -import { SelectorCell } from "./SelectorCell"; import { CheckboxCell } from "./CheckboxCell"; -import { SelectActionHelper } from "../helpers/SelectActionHelper"; +import { DataCell } from "./DataCell"; +import { SelectorCell } from "./SelectorCell"; -export interface RowProps { +export interface RowProps { className?: string; - CellComponent: CellComponent; - columns: C[]; + columns: GridColumn[]; item: ObjectItem; index: number; showSelectorCell?: boolean; - selectableWrapper?: (column: number, children: ReactElement) => ReactElement; - selectActionHelper: SelectActionHelper; - preview: boolean; + selectActions: SelectActionsService; totalRows: number; clickable: boolean; eventsController: EventsController; + checkboxColumnEnabled: boolean; } -export function Row(props: RowProps): ReactElement { - const { CellComponent: Cell, selectActionHelper, preview, totalRows, eventsController } = props; - const selected = selectActionHelper.isSelected(props.item); - const ariaSelected = selectActionHelper.selectionType === "None" ? undefined : selected; +export function Row(props: RowProps): ReactElement { + const { selectActions, totalRows, eventsController } = props; + const selected = selectActions.isSelected(props.item); + const ariaSelected = selectActions.selectionType === "None" ? undefined : selected; const borderTop = props.index === 0; return (
- {selectActionHelper.showCheckboxColumn && ( + {props.checkboxColumnEnabled && ( (props: RowProps): ReactElement { /> )} {props.columns.map((column, baseIndex) => { - const cell = ( - ); - - return preview ? props.selectableWrapper?.(baseIndex, cell) : cell; })} {props.showSelectorCell && ( ; - columns: GridColumn[]; - columnsHidable: boolean; - eventsController: EventsController; - focusController: FocusTargetController; - interactive: boolean; - pageSize: number; - preview: boolean; - rowClass?: (item: ObjectItem) => string; - rows: ObjectItem[]; - selectableWrapper?: (column: number, children: ReactElement) => ReactElement; - selectActionHelper: SelectActionHelper; -} - -export function RowsRenderer(props: RowsRendererProps): ReactElement { +export const RowsRenderer = observer(function RowsRenderer(): ReactElement { + const rows = useRows().get(); + const config = useDatagridConfig(); + const { visibleColumns } = useColumnsStore(); + const rowClass = useRowClass(); + const { cellEventsController, focusController, selectActionHelper } = useLegacyContext(); return ( - - {props.rows.map((item, rowIndex) => { + + {rows.map((item, rowIndex) => { return ( ); })} ); -} +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 81a13fb18c..ee29bcfa20 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -1,27 +1,12 @@ -import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator"; -import { Pagination } from "@mendix/widget-plugin-grid/components/Pagination"; -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import classNames from "classnames"; -import { ListActionValue, ObjectItem } from "mendix"; -import { observer } from "mobx-react-lite"; -import { CSSProperties, Fragment, ReactElement, ReactNode } from "react"; -import { - LoadingTypeEnum, - PaginationEnum, - PagingPositionEnum, - ShowPagingButtonsEnum -} from "../../typings/DatagridProps"; - +import { ReactNode } from "react"; +import { ExportProgressDialog } from "../features/data-export/ExportProgressDialog"; +import { EmptyPlaceholder } from "../features/empty-message/EmptyPlaceholder"; import { SelectAllBar } from "../features/select-all/SelectAllBar"; import { SelectionProgressDialog } from "../features/select-all/SelectionProgressDialog"; -import { SelectActionHelper } from "../helpers/SelectActionHelper"; -import { useBasicData } from "../model/hooks/injection-hooks"; -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 { RefreshStatus } from "./RefreshStatus"; import { RowsRenderer } from "./RowsRenderer"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; @@ -29,245 +14,25 @@ import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; import { WidgetTopBar } from "./WidgetTopBar"; -export interface WidgetProps { - CellComponent: CellComponent; - className: string; - columnsDraggable: boolean; - columnsFilterable: boolean; - columnsHidable: boolean; - columnsResizable: boolean; - columnsSortable: boolean; - data: T[]; - emptyPlaceholderRenderer?: (renderWrapper: (children: ReactNode) => ReactElement) => ReactElement; - exporting: boolean; - filterRenderer: (renderWrapper: (children: ReactNode) => ReactElement, columnIndex: number) => ReactElement; - hasMoreItems: boolean; - headerContent?: ReactNode; - headerTitle?: string; - headerWrapperRenderer: (columnIndex: number, header: ReactElement) => ReactElement; - id: string; - numberOfItems?: number; - onExportCancel?: () => void; - page: number; - paginationType: PaginationEnum; - loadMoreButtonCaption?: string; - - pageSize: number; - paging: boolean; - pagingPosition: PagingPositionEnum; - showPagingButtons: ShowPagingButtonsEnum; - preview?: boolean; - processedRows: number; - rowClass?: (item: T) => string; - setPage?: (computePage: (prevPage: number) => number) => void; - styles?: CSSProperties; - rowAction?: ListActionValue; - showSelectAllToggle?: boolean; - isFirstLoad: boolean; - isFetchingNextBatch: boolean; - loadingType: LoadingTypeEnum; - columnsLoading: boolean; - showRefreshIndicator: boolean; - - // Helpers - cellEventsController: EventsController; - checkboxEventsController: EventsController; - selectActionHelper: SelectActionHelper; - focusController: FocusTargetController; - - visibleColumns: GridColumn[]; - availableColumns: GridColumn[]; - - columnsSwap: (source: ColumnId, target: [ColumnId, "after" | "before"]) => void; - setIsResizing: (status: boolean) => void; -} - -export const Widget = observer((props: WidgetProps): ReactElement => { - const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const basicData = useBasicData(); - const selectionEnabled = selectActionHelper.selectionType !== "None"; - - return ( - -
- - {exporting && ( - - )} - - ); -}); - -const Main = observer((props: WidgetProps): ReactElement => { - const { - CellComponent, - columnsHidable, - data: rows, - emptyPlaceholderRenderer, - hasMoreItems, - headerContent, - headerTitle, - loadMoreButtonCaption, - numberOfItems, - page, - pageSize, - paginationType, - paging, - pagingPosition, - preview, - showRefreshIndicator, - selectActionHelper, - setPage, - visibleColumns - } = props; - - const basicData = useBasicData(); - - const showHeader = !!headerContent; - const showTopBarPagination = paging && (pagingPosition === "top" || pagingPosition === "both"); - const showFooterPagination = paging && (pagingPosition === "bottom" || pagingPosition === "both"); - - const pagination = paging ? ( - setPage && setPage(() => page)} - nextPage={() => setPage && setPage(prev => prev + 1)} - numberOfItems={numberOfItems} - page={page} - pageSize={pageSize} - showPagingButtons={props.showPagingButtons} - previousPage={() => setPage && setPage(prev => prev - 1)} - pagination={paginationType} - /> - ) : null; - - const cssGridStyles = gridStyle(visibleColumns, { - selectItemColumn: selectActionHelper.showCheckboxColumn, - visibilitySelectorColumn: columnsHidable - }); - - const selectionEnabled = selectActionHelper.selectionType !== "None"; - +export function Widget(props: { onExportCancel?: () => void }): ReactNode { return ( - - - {showHeader && {headerContent}} + + + - - + + - {showRefreshIndicator ? : null} - - - {(rows.length === 0 || preview) && - emptyPlaceholderRenderer && - emptyPlaceholderRenderer(children => ( -
-
{children}
-
- ))} + + + +
- -
+ + + + ); -}); - -function gridStyle(columns: GridColumn[], optional: OptionalColumns): CSSProperties { - const columnSizes = columns.map(c => c.getCssWidth()); - - const sizes: string[] = []; - - if (optional.selectItemColumn) { - sizes.push("48px"); - } - - sizes.push(...columnSizes); - - if (optional.visibilitySelectorColumn) { - sizes.push("54px"); - } - - return { - gridTemplateColumns: sizes.join(" ") - }; } - -type OptionalColumns = { - selectItemColumn?: boolean; - visibilitySelectorColumn?: boolean; -}; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 161cde501f..703af7cf16 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -1,42 +1,41 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; -import { PaginationEnum } from "../../typings/DatagridProps"; +import { ReactElement } from "react"; import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; +import { useDatagridConfig, usePaginationService, useTexts } from "../model/hooks/injection-hooks"; +import { Pagination } from "./Pagination"; -type WidgetFooterProps = { - pagination: ReactNode; - paginationType: PaginationEnum; - loadMoreButtonCaption?: string; - hasMoreItems: boolean; - setPage?: (computePage: (prevPage: number) => number) => void; -} & ComponentPropsWithoutRef<"div">; - -export const WidgetFooter = observer(function WidgetFooter(props: WidgetFooterProps): ReactElement | null { - const { pagination, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; +export const WidgetFooter = observer(function WidgetFooter(): ReactElement | null { + const config = useDatagridConfig(); + const paging = usePaginationService(); + const { loadMoreButtonCaption } = useTexts(); const selectionCounterVM = useSelectionCounterViewModel(); return ( -
+
- {hasMoreItems && paginationType === "loadMore" && ( +
- )} -
{pagination}
+
+
+ + + +
); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx index f55c0db551..aaa2fe35af 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx @@ -1,19 +1,30 @@ -import { ComponentPropsWithoutRef, ReactElement } from "react"; +import { getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; +import { getGlobalSelectionContext, useCreateSelectionContextValue } from "@mendix/widget-plugin-grid/selection"; +import { PropsWithChildren, ReactElement, ReactNode } from "react"; +import { useDatagridFilterAPI, useMainGate, useSelectionHelper, useTexts } from "../model/hooks/injection-hooks"; -type WidgetHeaderProps = { - headerTitle?: string; -} & ComponentPropsWithoutRef<"div">; +const Selection = getGlobalSelectionContext(); +const FilterAPI = getGlobalFilterContextObject(); -export function WidgetHeader(props: WidgetHeaderProps): ReactElement | null { - const { children, headerTitle, ...rest } = props; +function HeaderContainer(props: PropsWithChildren): ReactElement { + const filterAPI = useDatagridFilterAPI(); + const selectionContext = useCreateSelectionContextValue(useSelectionHelper()); + return ( + + {props.children} + + ); +} - if (!children) { - return null; - } +export const WidgetHeader = function WidgetHeader(): ReactNode { + const { headerAriaLabel } = useTexts(); + const { filtersPlaceholder } = useMainGate().props; + + if (!filtersPlaceholder) return null; return ( -
- {children} +
+ {filtersPlaceholder}
); -} +}; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx deleted file mode 100644 index 794f86029f..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; -import { - getGlobalSelectionContext, - SelectionHelper, - useCreateSelectionContextValue -} from "@mendix/widget-plugin-grid/selection"; -import { memo, ReactElement, ReactNode } from "react"; -import { useDatagridFilterAPI } from "../model/hooks/injection-hooks"; - -interface WidgetHeaderContextProps { - children?: ReactNode; - selectionHelper?: SelectionHelper; -} - -const SelectionContext = getGlobalSelectionContext(); -const FilterContext = getGlobalFilterContextObject(); - -function HeaderContainer(props: WidgetHeaderContextProps): ReactElement { - const filterAPI = useDatagridFilterAPI(); - const selectionContext = useCreateSelectionContextValue(props.selectionHelper); - return ( - - {props.children} - - ); -} - -const component = memo(HeaderContainer); - -component.displayName = "WidgetHeaderContext"; - -// Override NamedExoticComponent type -export const WidgetHeaderContext = component as (props: WidgetHeaderContextProps) => ReactElement; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx index bba4ea22fa..d005ac1201 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx @@ -1,41 +1,21 @@ import classNames from "classnames"; import { observer } from "mobx-react-lite"; -import { ComponentPropsWithoutRef, ReactElement, useMemo, useRef } from "react"; -import { useSelectionDialogViewModel } from "../features/select-all/injection-hooks"; -import { SelectionMethod } from "../helpers/SelectActionHelper"; +import { PropsWithChildren, ReactElement } from "react"; +import { useDatagridRootVM } from "../model/hooks/injection-hooks"; -type P = ComponentPropsWithoutRef<"div">; - -export interface WidgetRootProps extends P { - className?: string; - selection?: boolean; - selectionMethod: SelectionMethod; - exporting?: boolean; -} - -export const WidgetRoot = observer(function WidgetRoot(props: WidgetRootProps): ReactElement { - const ref = useRef(null); - const { className, selectionMethod, selection, exporting, children, ...rest } = props; - const { isOpen: selectingAllPages } = useSelectionDialogViewModel(); - const style = useMemo(() => { - const s = { ...props.style }; - if ((exporting || selectingAllPages) && ref.current) { - s.height = ref.current.offsetHeight; - } - return s; - }, [props.style, exporting, selectingAllPages]); +export const WidgetRoot = observer(function WidgetRoot({ children }: PropsWithChildren): ReactElement { + const vm = useDatagridRootVM(); return (
{children} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx index cdf48e1cd3..390194bc0c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx @@ -1,27 +1,27 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; +import { ReactElement } from "react"; import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; +import { useDatagridConfig } from "../model/hooks/injection-hooks"; +import { Pagination } from "./Pagination"; -type WidgetTopBarProps = { - pagination: ReactNode; -} & ComponentPropsWithoutRef<"div">; - -export const WidgetTopBar = observer(function WidgetTopBar(props: WidgetTopBarProps): ReactElement { - const { pagination, ...rest } = props; - const selectionCounterVM = useSelectionCounterViewModel(); +export const WidgetTopBar = observer(function WidgetTopBar(): ReactElement { + const config = useDatagridConfig(); + const selectionCounter = useSelectionCounterViewModel(); return ( -
+
- +
- {pagination} + + +
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 daa0d9572b..a3aaed7e23 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx @@ -1,10 +1,26 @@ import "@testing-library/jest-dom"; import { render } from "@testing-library/react"; +import { ContainerProvider } from "brandi-react"; +import { createDatagridContainer } from "../../model/containers/createDatagridContainer"; +import { CORE_TOKENS as CORE } from "../../model/tokens"; +import { mockContainerProps } from "../../utils/test-utils"; +import { ColumnProvider } from "../ColumnProvider"; import { ColumnResizer } from "../ColumnResizer"; describe("Column Resizer", () => { it("renders the structure correctly", () => { - const component = render(); + const props = mockContainerProps(); + const [container] = createDatagridContainer(props); + const columnsStore = container.get(CORE.columnsStore); + const column = columnsStore.visibleColumns[0]; + + const component = render( + + + + + + ); expect(component).toMatchSnapshot(); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx new file mode 100644 index 0000000000..fd42081e81 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx @@ -0,0 +1,33 @@ +import { render } from "@testing-library/react"; +import { ContainerProvider } from "brandi-react"; +import { createDatagridContainer } from "../../model/containers/createDatagridContainer"; +import { mockContainerProps } from "../../utils/test-utils"; +import { Grid } from "../Grid"; + +describe("Grid", () => { + it("renders without crashing", () => { + const props = mockContainerProps(); + const [container] = createDatagridContainer(props); + const { asFragment } = render( + + Test + + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("renders without selector column", () => { + const [container] = createDatagridContainer({ + ...mockContainerProps(), + columnsHidable: false + }); + const { asFragment } = render( + + Test + + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx deleted file mode 100644 index 4eca3ba61e..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createElement } from "react"; -import { ColumnId, GridColumn } from "../../typings/GridColumn"; -import { ColumnResizer } from "../ColumnResizer"; -import { Header, HeaderProps } from "../Header"; - -describe("Header", () => { - it("renders the structure correctly", () => { - const component = render(
); - - 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.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when resizable", () => { - const props = mockHeaderProps(); - props.column.canResize = true; - props.resizable = true; - const component = render(
); - - 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.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when filterable with no custom filter", () => { - const props = mockHeaderProps(); - props.filterable = true; - const component = render(
); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when filterable with custom filter", () => { - const props = mockHeaderProps(); - props.filterable = true; - props.filterWidget = ( -
- - -
- ); - const component = render(
); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("calls setSortBy store function with correct parameters when sortable", async () => { - const user = userEvent.setup(); - const mockedFunction = jest.fn(); - 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"); - - expect(button).toBeInTheDocument(); - await user.click(button); - expect(mockedFunction).toHaveBeenCalled(); - }); - - it("renders the structure correctly when filterable with custom classes", () => { - const props = mockHeaderProps(); - props.filterable = true; - - const component = render(
); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when is hidden and preview", () => { - const props = mockHeaderProps(); - props.column.initiallyHidden = true; - props.hidable = true; - props.preview = true; - - const component = render(
); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when value is empty", () => { - const props = mockHeaderProps(); - props.column.header = " "; - - const component = render(
); - - expect(component.asFragment()).toMatchSnapshot(); - }); -}); - -function mockHeaderProps(): HeaderProps { - return { - gridId: "dg1", - column: { - columnId: "dg1-column0" as ColumnId, - columnIndex: 0, - header: "Test", - sortDir: 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, - filterable: false, - filterWidget: undefined, - hidable: false, - resizable: false, - resizer: , - sortable: false, - swapColumns: jest.fn(), - setDropTarget: jest.fn(), - setIsDragging: jest.fn() - }; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx deleted file mode 100644 index 8f9f717f6f..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ /dev/null @@ -1,691 +0,0 @@ -import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; -import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/main"; -import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; -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 } 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 { LegacyContext, LegacyRootScope } 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"; - -// you can also pass the mock implementation -// to jest.fn as an argument -window.IntersectionObserver = jest.fn(() => ({ - root: null, - rootMargin: "", - thresholds: [0, 1], - disconnect: jest.fn(), - observe: jest.fn(), - unobserve: jest.fn(), - takeRecords: jest.fn() -})); - -function withCtx( - widgetProps: WidgetProps, - contextOverrides: Partial = {} -): 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 SelectionCounterViewModel, - ...contextOverrides - }; - - return ( - - - - ); -} - -// Helper function to render Widget with root context -function renderWithRootContext( - widgetProps: WidgetProps, - contextOverrides: Partial = {} -): ReturnType { - return render(withCtx(widgetProps, contextOverrides)); -} - -// TODO: Rewrite or delete these tests -// eslint-disable-next-line jest/no-disabled-tests -describe.skip("Table", () => { - it("renders the structure correctly", () => { - const component = renderWithRootContext(mockWidgetProps()); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with sorting", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsSortable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with resizing", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsResizable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with dragging", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsDraggable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with filtering", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsFilterable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with hiding", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsHidable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with paging", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), paging: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with custom filtering", () => { - const props = mockWidgetProps(); - const columns = [column("Test")].map((col, index) => mockGridColumn(col, index)); - props.columnsFilterable = true; - props.visibleColumns = columns; - props.availableColumns = columns; - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with empty placeholder", () => { - const component = renderWithRootContext({ - ...mockWidgetProps(), - emptyPlaceholderRenderer: renderWrapper => renderWrapper(
) - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with column alignments", () => { - const props = mockWidgetProps(); - const columns = [ - column("Test", col => { - col.alignment = "center"; - }), - column("Test 2", col => (col.alignment = "right")) - ].map((col, index) => mockGridColumn(col, index)); - - props.visibleColumns = columns; - props.availableColumns = columns; - - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with dynamic row class", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), rowClass: () => "myclass" }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly for preview when no header is provided", () => { - const props = mockWidgetProps(); - const columns = [column("", col => (col.alignment = "center"))].map((col, index) => mockGridColumn(col, index)); - props.preview = true; - props.visibleColumns = columns; - props.availableColumns = columns; - - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with header wrapper", () => { - const component = renderWithRootContext({ - ...mockWidgetProps(), - headerWrapperRenderer: (index, header) => ( -
- {header} -
- ) - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with header filters and a11y", () => { - const component = renderWithRootContext({ - ...mockWidgetProps(), - headerContent: ( -
- -
- ), - headerTitle: "filter title" - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - describe("with selection method checkbox", () => { - let props: ReturnType; - - beforeEach(() => { - props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); - props.paging = true; - props.data = objectItems(3); - }); - - it("render method class", () => { - const { container } = renderWithRootContext(props, {}); - - expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-checkbox"); - }); - - it("render an extra column and add class to each selected row", () => { - props.selectActionHelper.isSelected = () => true; - - const { asFragment } = renderWithRootContext(props, {}); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("render correct number of checked checkboxes", () => { - const [a, b, c, d, e, f] = (props.data = objectItems(6)); - let selection: ObjectItem[] = []; - props.selectActionHelper.isSelected = item => selection.includes(item); - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const getChecked = () => screen.getAllByRole("checkbox").filter(elt => elt.checked); - - const { rerender } = render(withCtx(props)); - - expect(getChecked()).toHaveLength(0); - - selection = [a, b, c]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(3); - - selection = [c]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(1); - - selection = [d, e]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(2); - - selection = [f, e, d, a]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(4); - }); - - it("call onSelect when checkbox is clicked", async () => { - const items = props.data; - const onSelect = jest.fn(); - props.selectActionHelper.onSelect = onSelect; - props.checkboxEventsController = new CheckboxEventsController( - item => ({ - item, - selectionMethod: props.selectActionHelper.selectionMethod, - selectionType: "Single", - selectionMode: "clear", - pageSize: props.pageSize - }), - onSelect, - jest.fn(), - jest.fn(), - jest.fn() - ); - - // renderWithRootContext(props, { - // basicData: { gridInteractive: true } as unknown as GridBasicData - // }); - - const checkbox1 = screen.getAllByRole("checkbox")[0]; - const checkbox3 = screen.getAllByRole("checkbox")[2]; - - await userEvent.click(checkbox1); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, true); - - await userEvent.click(checkbox1); - expect(onSelect).toHaveBeenCalledTimes(2); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, true); - - await userEvent.click(checkbox3); - expect(onSelect).toHaveBeenCalledTimes(3); - expect(onSelect).toHaveBeenLastCalledWith(items[2], false, true); - - await userEvent.click(checkbox3); - expect(onSelect).toHaveBeenCalledTimes(4); - expect(onSelect).toHaveBeenLastCalledWith(items[2], false, true); - }); - }); - - it("not render header checkbox when showCheckboxColumn is false", () => { - const props = mockWidgetProps(); - props.data = objectItems(5); - props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); - renderWithRootContext(props); - - const colheader = screen.getAllByRole("columnheader")[0]; - expect(queryByRole(colheader, "checkbox")).toBeNull(); - }); - - describe("with multi selection helper", () => { - it("render header checkbox if helper is given and checkbox state depends on the helper status", () => { - const props = mockWidgetProps(); - props.data = objectItems(5); - props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); - - const renderWithStatus = (_status: MultiSelectionStatus): ReturnType => { - return renderWithRootContext(props); - }; - - renderWithStatus("none"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).not.toBeChecked(); - - cleanup(); - renderWithStatus("some"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); - - cleanup(); - renderWithStatus("all"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); - }); - - it("not render header checkbox if method is rowClick", () => { - const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); - - renderWithRootContext(props); - - const colheader = screen.getAllByRole("columnheader")[0]; - expect(queryByRole(colheader, "checkbox")).toBeNull(); - }); - - it("call onSelectAll when header checkbox is clicked", async () => { - const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); - props.selectActionHelper.onSelectAll = jest.fn(); - - renderWithRootContext(props, {}); - - const checkbox = screen.getAllByRole("checkbox")[0]; - - await userEvent.click(checkbox); - expect(props.selectActionHelper.onSelectAll).toHaveBeenCalledTimes(1); - - await userEvent.click(checkbox); - expect(props.selectActionHelper.onSelectAll).toHaveBeenCalledTimes(2); - }); - }); - - describe("with selection method rowClick", () => { - let props: ReturnType; - - beforeEach(() => { - props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); - props.paging = true; - props.data = objectItems(3); - }); - - it("render method class", () => { - const { container } = renderWithRootContext(props, {}); - - expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-click"); - }); - - it("add class to each selected cell", () => { - props.selectActionHelper.isSelected = () => true; - - const { asFragment } = renderWithRootContext(props, {}); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("call onSelect when cell is clicked", async () => { - const items = props.data; - const onSelect = jest.fn(); - const columns = [column("Column A"), column("Column B")].map((col, index) => mockGridColumn(col, index)); - props.visibleColumns = columns; - props.availableColumns = columns; - props.cellEventsController = new CellEventsController( - item => ({ - item, - selectionType: props.selectActionHelper.selectionType, - selectionMethod: props.selectActionHelper.selectionMethod, - selectionMode: "clear", - clickTrigger: "none", - pageSize: props.pageSize - }), - onSelect, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ); - - renderWithRootContext(props, {}); - - const rows = screen.getAllByRole("row").slice(1); - expect(rows).toHaveLength(3); - - const [row1, row2] = rows; - const [cell1, cell2] = getAllByRole(row1, "gridcell"); - const [cell3, cell4] = getAllByRole(row2, "gridcell"); - - const sleep = (t: number): Promise => new Promise(res => setTimeout(res, t)); - - // Click cell1 two times - await userEvent.click(cell1); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - await userEvent.click(cell1); - expect(onSelect).toHaveBeenCalledTimes(2); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - // Click cell2 - await userEvent.click(cell2); - expect(onSelect).toHaveBeenCalledTimes(3); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - // Click cell3 and cell4 - await userEvent.click(cell4); - expect(onSelect).toHaveBeenCalledTimes(4); - expect(onSelect).toHaveBeenLastCalledWith(items[1], false, false); - await sleep(320); - - await userEvent.click(cell3); - expect(onSelect).toHaveBeenCalledTimes(5); - expect(onSelect).toHaveBeenLastCalledWith(items[1], false, false); - }); - }); - - describe("when selecting is enabled, allow the user to select multiple rows", () => { - let items: ReturnType; - let props: ReturnType; - let selection: SelectionMultiValue; - let ds: ListValue; - - function WidgetWithSelectionHelper({ - selectionMethod, - ...props - }: WidgetProps & { - selectionMethod: ItemSelectionMethodEnum; - }): ReactElement { - const helper = useSelectionHelper(selection, ds, undefined, "always clear"); - const selectHelper = useSelectActionHelper( - { - itemSelection: selection, - itemSelectionMethod: selectionMethod, - itemSelectionMode: "clear", - showSelectAllToggle: false, - pageSize: 5 - }, - helper - ); - const cellEventsController = useCellEventsController( - selectHelper, - new ClickActionHelper("single", null), - props.focusController - ); - - 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 SelectionCounterViewModel - }; - - return ( - - - - ); - } - - function setup( - jsx: ReactElement - ): ReturnType & { rows: HTMLElement[]; user: ReturnType } { - const result = render(jsx); - const user = userEvent.setup(); - const rows = screen.getAllByRole("row").slice(1); - - return { - user, - rows, - ...result - }; - } - - beforeEach(() => { - ds = list(20); - items = ds.items!; - props = mockWidgetProps(); - selection = new SelectionMultiValueBuilder().build(); - props.data = items; - const columns = [ - column("Name"), - column("Description"), - column("Amount", col => { - col.showContentAs = "customContent"; - col.content = listWidget(() => ); - }) - ].map((col, index) => mockGridColumn(col, index)); - - props.visibleColumns = columns; - props.availableColumns = columns; - }); - - it("selects multiple rows with shift+click on a row", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - await user.click(rows[10].children[2]); - expect(selection.selection).toEqual([items[10]]); - - await user.keyboard("[ShiftLeft>]"); - - await user.click(rows[14].children[2]); - expect(selection.selection).toHaveLength(5); - expect(selection.selection).toEqual(items.slice(10, 15)); - - await user.click(rows[4].children[2]); - expect(selection.selection).toHaveLength(7); - expect(selection.selection).toEqual(items.slice(4, 11)); - - await user.click(rows[8].children[2]); - expect(selection.selection).toHaveLength(3); - expect(selection.selection).toEqual(items.slice(8, 11)); - }); - - it("selects multiple rows with shift+click on a checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - await user.click(getByRole(rows[10], "checkbox")); - expect(selection.selection).toEqual([items[10]]); - - await user.keyboard("[ShiftLeft>]"); - - await user.click(getByRole(rows[14], "checkbox")); - expect(selection.selection).toHaveLength(5); - expect(selection.selection).toEqual(items.slice(10, 15)); - - await user.click(getByRole(rows[4], "checkbox")); - expect(selection.selection).toHaveLength(7); - expect(selection.selection).toEqual(items.slice(4, 11)); - - await user.click(getByRole(rows[8], "checkbox")); - expect(selection.selection).toHaveLength(3); - expect(selection.selection).toEqual(items.slice(8, 11)); - }); - - it("selects all available rows with metaKey+a and method checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [checkbox] = getAllByRole(row, "checkbox"); - await user.click(checkbox); - expect(checkbox).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with metaKey+a and method rowClick", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [cell] = getAllByRole(row, "gridcell"); - await user.click(cell); - expect(cell).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with ctrlKey+a and method checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [checkbox] = getAllByRole(row, "checkbox"); - await user.click(checkbox); - expect(checkbox).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with ctrlKey+a and method rowClick", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [cell] = getAllByRole(row, "gridcell"); - await user.click(cell); - expect(cell).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(20); - }); - - it("must not select rows, when metaKey+a or ctrlKey+a pressed in custom widget", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [input] = screen.getAllByRole("textbox"); - await user.click(input); - await user.keyboard("Hello, world!"); - expect(selection.selection).toHaveLength(0); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(0); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(0); - }); - }); - - describe("when has interactive element", () => { - it("should not prevent default on keyboard input (space and Enter)", async () => { - const items = objectItems(3); - - const props = mockWidgetProps(); - const content = listWidget(() =>