From 9e32565eb33847ea224b615e1b91705451e03397 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 19 Sep 2024 16:14:07 +0200 Subject: [PATCH] poc: Property filter enum props POC --- package-lock.json | 1 + package.json | 3 +- pages/property-filter/common-props.tsx | 9 +- ...operty-filter-editor-permutations.page.tsx | 3 + ...plit-panel-app-layout-integration.page.tsx | 2 +- pages/property-filter/table.data.ts | 132 +++++------ scripts/install-peer-dependency.js | 59 +++++ src/multiselect/embedded.tsx | 223 ++++++++++++++++++ src/multiselect/styles.scss | 13 + src/property-filter/__tests__/common.tsx | 1 + src/property-filter/i18n-utils.ts | 5 +- src/property-filter/interfaces.ts | 3 + src/property-filter/internal.tsx | 55 +++-- src/property-filter/property-editor.tsx | 85 ++++++- src/property-filter/token-editor-inputs.tsx | 62 ++++- 15 files changed, 546 insertions(+), 110 deletions(-) create mode 100644 scripts/install-peer-dependency.js create mode 100644 src/multiselect/embedded.tsx diff --git a/package-lock.json b/package-lock.json index f57e92ffdb..b6878498d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@cloudscape-design/components", "version": "3.0.0", + "hasInstallScript": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", diff --git a/package.json b/package.json index 67ea1db6bb..668883e229 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "start:watch": "gulp watch", "start:dev": "cross-env NODE_ENV=development webpack serve --config pages/webpack.config.js", "start:integ": "cross-env NODE_ENV=development webpack serve --config pages/webpack.config.integ.js", - "prepare": "husky" + "prepare": "husky", + "postinstall": "node ./scripts/install-peer-dependency.js collection-hooks:feat-property-filter-enum-props-2" }, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", diff --git a/pages/property-filter/common-props.tsx b/pages/property-filter/common-props.tsx index 7536943ae5..221072050c 100644 --- a/pages/property-filter/common-props.tsx +++ b/pages/property-filter/common-props.tsx @@ -30,7 +30,7 @@ export const columnDefinitions = [ sortingField: 'state', header: 'State', type: 'enum', - getLabel: getStateLabel, + getLabel: (value: any) => (Array.isArray(value) ? value.map(getStateLabel).join(', ') : getStateLabel(value)), propertyLabel: 'State', cell: (item: TableItem) => getStateLabel(item.state), }, @@ -40,7 +40,7 @@ export const columnDefinitions = [ header: 'Stopped', type: 'boolean', propertyLabel: 'Stopped', - cell: (item: TableItem) => item.state === 0, + cell: (item: TableItem) => item.state === 'STOPPED', }, { id: 'instancetype', @@ -236,7 +236,10 @@ export const filteringProperties: readonly PropertyFilterProps.FilteringProperty let groupValuesLabel = `${def.propertyLabel} values`; if (def.type === 'enum') { - operators = ['=', '!='].map(operator => ({ operator, format: def.getLabel })); + operators = [ + ...['=', '!='].map(operator => ({ operator, format: def.getLabel, valueType: 'enum' })), + ...[':', '!:'].map(operator => ({ operator, format: def.getLabel, valueType: 'auto' })), + ]; } if (def.type === 'text') { diff --git a/pages/property-filter/property-filter-editor-permutations.page.tsx b/pages/property-filter/property-filter-editor-permutations.page.tsx index e4035724ac..4c0d401ec8 100644 --- a/pages/property-filter/property-filter-editor-permutations.page.tsx +++ b/pages/property-filter/property-filter-editor-permutations.page.tsx @@ -32,6 +32,7 @@ const nameProperty: InternalFilteringProperty = { groupValuesLabel: 'Name values', operators: ['=', '!='], defaultOperator: '=', + getValueType: () => 'auto', getValueFormatter: () => null, getValueFormRenderer: () => null, externalProperty, @@ -43,6 +44,7 @@ const dateProperty: InternalFilteringProperty = { groupValuesLabel: 'Date values', operators: ['=', '!='], defaultOperator: '=', + getValueType: () => 'auto', getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd') : ''), getValueFormRenderer: () => @@ -60,6 +62,7 @@ const dateTimeProperty: InternalFilteringProperty = { groupValuesLabel: 'Date time values', operators: ['=', '!='], defaultOperator: '=', + getValueType: () => 'auto', getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd hh:mm') : ''), getValueFormRenderer: () => diff --git a/pages/property-filter/split-panel-app-layout-integration.page.tsx b/pages/property-filter/split-panel-app-layout-integration.page.tsx index 8f4b15cdfb..8333472b5c 100644 --- a/pages/property-filter/split-panel-app-layout-integration.page.tsx +++ b/pages/property-filter/split-panel-app-layout-integration.page.tsx @@ -49,7 +49,7 @@ export default function () { const filteringOptions = propertyFilterProps.filteringOptions.map(option => { if (option.propertyKey === 'state') { - option.label = states[parseInt(option.value)]; + option.label = states[option.value]; } return option; }); diff --git a/pages/property-filter/table.data.ts b/pages/property-filter/table.data.ts index eba1198268..ce2fa9b6e7 100644 --- a/pages/property-filter/table.data.ts +++ b/pages/property-filter/table.data.ts @@ -4,7 +4,7 @@ export interface TableItem { order?: number; instanceid?: string; - state?: number; + state?: string; instancetype?: string; averagelatency?: number; availablestorage?: number; @@ -18,16 +18,16 @@ export interface TableItem { lasteventat?: Date; } -export const states: Record = { - 0: 'Stopped', - 1: 'Stopping', - 2: 'Pending', +export const states: Record = { + STOPPED: 'Stopped', + STOPPING: 'Stopping', + PENDING: 'Pending', }; export const allItems: TableItem[] = [ { instanceid: 'i-2dc5ce28a0328391', - state: 0, + state: 'STOPPED', instancetype: 't3.small', averagelatency: 771, availablestorage: 8.9, @@ -41,7 +41,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d0312e022392efa0', - state: 0, + state: 'STOPPED', instancetype: 't2.small', averagelatency: 216, availablestorage: 7.99, @@ -55,7 +55,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-070eef935c1301e6', - state: 0, + state: 'STOPPED', instancetype: 't3.nano', averagelatency: 352, availablestorage: 8.2, @@ -69,7 +69,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-0eeaae622e074e21', - state: 0, + state: 'STOPPED', instancetype: 't2.medium', averagelatency: 895, availablestorage: 4.23, @@ -83,7 +83,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-799860926d39b2a3', - state: 0, + state: 'STOPPED', instancetype: 't2.medium', averagelatency: 600, availablestorage: 1.71, @@ -97,7 +97,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-f64935677691848b', - state: 0, + state: 'STOPPED', instancetype: 't3.medium', averagelatency: 461, availablestorage: 2.71, @@ -111,7 +111,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d5b5e73917af69a8', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 153, availablestorage: 9.56, @@ -125,7 +125,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-2a5bdc6c48fa8e5c', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 107, availablestorage: 3.54, @@ -139,7 +139,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-393c4d4a25ca3dba', - state: 0, + state: 'STOPPED', instancetype: 't2.micro', averagelatency: 53, availablestorage: 3.27, @@ -153,7 +153,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-c28fbdbb073ec4de', - state: 0, + state: 'STOPPED', instancetype: 't2.medium', averagelatency: 724, availablestorage: 5.2, @@ -167,7 +167,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-e876fb65dc771c53', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 981, availablestorage: 6.86, @@ -181,7 +181,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d92d0e29fa3a0eda', - state: 0, + state: 'STOPPED', instancetype: 't3.small', averagelatency: 303, availablestorage: 6.07, @@ -195,7 +195,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-7911f4562405cb04', - state: 0, + state: 'STOPPED', instancetype: 't3.small', averagelatency: 718, availablestorage: 2.9, @@ -209,7 +209,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d3e9e4c068d4df6a', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 44, availablestorage: 6.1, @@ -223,7 +223,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-9966b9f79fc2afac', - state: 0, + state: 'STOPPED', instancetype: 't3.nano', averagelatency: 652, availablestorage: 0.31, @@ -237,7 +237,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-f202265d52b3d9b6', - state: 1, + state: 'STOPPING', instancetype: 't2.micro', averagelatency: 743, availablestorage: 3.69, @@ -251,7 +251,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-a6b569bc16be3756', - state: 0, + state: 'STOPPED', instancetype: 't2.micro', averagelatency: 304, availablestorage: 6.93, @@ -265,7 +265,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-f8e3d9fffd82bd62', - state: 1, + state: 'STOPPING', instancetype: 't2.large', averagelatency: 339, availablestorage: 0.68, @@ -279,7 +279,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d1f8d3023c360cb4', - state: 1, + state: 'STOPPING', instancetype: 't2.large', averagelatency: 945, availablestorage: 7.26, @@ -293,7 +293,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-64ed85f898d0a950', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 402, availablestorage: 4.17, @@ -307,7 +307,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-6afd6b0ebae03bd6', - state: 0, + state: 'STOPPED', instancetype: 't2.small', averagelatency: 845, availablestorage: 9.73, @@ -321,7 +321,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-068993f1f76b09e1', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 184, availablestorage: 5.2, @@ -335,7 +335,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-1cd4a64fc26a8fe9', - state: 1, + state: 'STOPPING', instancetype: 't3.nano', averagelatency: 995, availablestorage: 0.24, @@ -349,7 +349,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-835d004c46769aed', - state: 0, + state: 'STOPPED', instancetype: 't3.small', averagelatency: 800, availablestorage: 4.03, @@ -363,7 +363,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-5441bf2e91565e24', - state: 0, + state: 'STOPPED', instancetype: 't3.medium', averagelatency: 283, availablestorage: 7.38, @@ -377,7 +377,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-a57d0a6a6d20d73b', - state: 1, + state: 'STOPPING', instancetype: 't2.small', averagelatency: 705, availablestorage: 6.78, @@ -391,7 +391,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-0b6646fde4598f8d', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 375, availablestorage: 3.41, @@ -405,7 +405,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-5313687f75346895', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 219, availablestorage: 1.97, @@ -419,7 +419,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-7173f776b4b30c05', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 17, availablestorage: 8.63, @@ -433,7 +433,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-fcfc136cb96b30c4', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 835, availablestorage: 7.17, @@ -447,7 +447,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-8c6a328351a438ff', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 73, availablestorage: 6.02, @@ -461,7 +461,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-cd323ed6664c7dc5', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 875, availablestorage: 7.42, @@ -475,7 +475,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-a7251b1805f9df3d', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 342, availablestorage: 9.64, @@ -489,7 +489,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-9f11e46b6c54a2a1', - state: 0, + state: 'STOPPED', instancetype: 't3.nano', averagelatency: 792, availablestorage: 6.99, @@ -503,7 +503,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-6f733cc0de79c2cc', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 782, availablestorage: 7.1, @@ -517,7 +517,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-b994cfe3823d78b4', - state: 0, + state: 'STOPPED', instancetype: 't2.small', averagelatency: 242, availablestorage: 2.71, @@ -531,7 +531,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-82b8f4ef5d25d17c', - state: 2, + state: 'PENDING', instancetype: 't2.large', averagelatency: 497, availablestorage: 2.03, @@ -545,7 +545,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-1d9468c0fdc6d337', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 219, availablestorage: 2.34, @@ -559,7 +559,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-1d56f94cf858be59', - state: 0, + state: 'STOPPED', instancetype: 't3.micro', averagelatency: 45, availablestorage: 0.57, @@ -573,7 +573,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-59129472a88790c4', - state: 0, + state: 'STOPPED', instancetype: 't3.nano', averagelatency: 154, availablestorage: 3.45, @@ -587,7 +587,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-b761d2801b356d89', - state: 1, + state: 'STOPPING', instancetype: 't2.medium', averagelatency: 885, availablestorage: 4.06, @@ -601,7 +601,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-9b50eea20c1d2813', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 458, availablestorage: 3.06, @@ -615,7 +615,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-a19a9633e1c5ba8f', - state: 1, + state: 'STOPPING', instancetype: 't2.nano', averagelatency: 30, availablestorage: 4.64, @@ -629,7 +629,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-85c31338cf96a6b9', - state: 0, + state: 'STOPPED', instancetype: 't3.medium', averagelatency: 420, availablestorage: 4.66, @@ -643,7 +643,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-2a63773bd3bbc950', - state: 1, + state: 'STOPPING', instancetype: 't2.micro', averagelatency: 685, availablestorage: 9.49, @@ -657,7 +657,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-c71adc85f62dc441', - state: 0, + state: 'STOPPED', instancetype: 't2.medium', averagelatency: 639, availablestorage: 9.37, @@ -671,7 +671,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-7d78145659f6edf8', - state: 0, + state: 'STOPPED', instancetype: 't2.micro', averagelatency: 141, availablestorage: 3.37, @@ -685,7 +685,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d64836cefed723f9', - state: 0, + state: 'STOPPED', instancetype: 't2.micro', averagelatency: 259, availablestorage: 2.31, @@ -699,7 +699,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-0854aa3ca406d6de', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 672, availablestorage: 2.09, @@ -713,7 +713,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-d50d96196e1da02f', - state: 0, + state: 'STOPPED', instancetype: 't3.medium', averagelatency: 636, availablestorage: 7.76, @@ -727,7 +727,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-bf46a8fa4f894969', - state: 1, + state: 'STOPPING', instancetype: 't2.medium', averagelatency: 236, availablestorage: 9.14, @@ -741,7 +741,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-a3358a2493af65da', - state: 0, + state: 'STOPPED', instancetype: 't2.micro', averagelatency: 478, availablestorage: 8.13, @@ -755,7 +755,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-1ca6c551cd98b0bc', - state: 0, + state: 'STOPPED', instancetype: 't3.small', averagelatency: 29, availablestorage: 3.5, @@ -769,7 +769,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-78363f3b35fe1638', - state: 1, + state: 'STOPPING', instancetype: 't2.nano', averagelatency: 74, availablestorage: 6.37, @@ -783,7 +783,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-36cb4d638866ef4b', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 819, availablestorage: 3.16, @@ -797,7 +797,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-dde47b18e8183070', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 305, availablestorage: 8.34, @@ -811,7 +811,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-86f3902256c13e75', - state: 0, + state: 'STOPPED', instancetype: 't2.large', averagelatency: 521, availablestorage: 5.05, @@ -825,7 +825,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-64329dd06110114b', - state: 0, + state: 'STOPPED', instancetype: 't2.nano', averagelatency: 478, availablestorage: 7.33, @@ -839,7 +839,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-36b91360e881739d', - state: 0, + state: 'STOPPED', instancetype: 't3.micro', averagelatency: 733, availablestorage: 3.26, @@ -853,7 +853,7 @@ export const allItems: TableItem[] = [ }, { instanceid: 'i-3b44795b1fea36ac', - state: 0, + state: 'STOPPED', instancetype: 't3.large', averagelatency: 636, availablestorage: 3.57, @@ -868,7 +868,7 @@ export const allItems: TableItem[] = [ ].map((item, indx) => ({ order: indx, ...item, - stopped: item.state === 0, + stopped: item.state === 'STOPPED', releasedate: new Date(new Date(item.launchdate).getTime() - getRandomTimeHours(10, 2000)), launchdate: new Date(item.launchdate), lasteventat: new Date(item.lasteventat), diff --git a/scripts/install-peer-dependency.js b/scripts/install-peer-dependency.js new file mode 100644 index 0000000000..0eef303f3f --- /dev/null +++ b/scripts/install-peer-dependency.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Can be used in postinstall script like so: +// "postinstall": "node ./scripts/install-peer-dependency.js collection-hooks:property-filter-token-groups" +// where "collection-hooks" is the package to fetch and "property-filter-token-groups" is the branch name in GitHub. + +const { execSync } = require('child_process'); +const path = require('path'); +const os = require('os'); + +const args = process.argv.slice(2); +if (args.length < 1) { + console.error('Usage: install-peer-dependency.js :'); + process.exit(1); +} +const [packageName, targetBranch] = args[0].split(':'); +const targetRepository = `https://github.com/cloudscape-design/${packageName}.git`; +const nodeModulesPackagePath = path.join(process.cwd(), 'node_modules', '@cloudscape-design', packageName); +const tempDir = path.join(os.tmpdir(), `temp-${packageName}`); + +// Clone the repository and checkout the branch +console.log(`Cloning ${packageName}:${targetBranch}...`); +execCommand(`git clone ${targetRepository} ${tempDir}`); +process.chdir(tempDir); +execCommand(`git checkout ${targetBranch}`); + +// Install dependencies and build +console.log(`Installing dependencies and building ${packageName}...`); +execCommand('npm install'); +execCommand('npm run build'); + +// Remove existing peer dependency in node_modules +console.log(`Removing existing ${packageName} from node_modules...`); +execCommand(`rm -rf ${nodeModulesPackagePath}`); + +// Copy built peer dependency to node_modules +console.log(`Copying build ${targetRepository} to node_modules...`); +execCommand(`mkdir -p ${nodeModulesPackagePath}`); +execCommand(`cp -R ${tempDir}/lib/* ${nodeModulesPackagePath}`); + +// Clean up +console.log('Cleaning up...'); +execCommand(`rm -rf ${tempDir}`); + +console.log(`${packageName} has been successfully installed from branch ${targetBranch}!`); + +function execCommand(command, options = {}) { + try { + execSync(command, { stdio: 'inherit', ...options }); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(`Error message: ${error.message}`); + console.error(`Stdout: ${error.stdout && error.stdout.toString()}`); + console.error(`Stderr: ${error.stderr && error.stderr.toString()}`); + throw error; + } +} diff --git a/src/multiselect/embedded.tsx b/src/multiselect/embedded.tsx new file mode 100644 index 0000000000..f19e1b0c40 --- /dev/null +++ b/src/multiselect/embedded.tsx @@ -0,0 +1,223 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useEffect, useRef } from 'react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { useInternalI18n } from '../i18n/context'; +import DropdownFooter from '../internal/components/dropdown-footer/index.js'; +import { DropdownStatusProps, useDropdownStatus } from '../internal/components/dropdown-status'; +import { OptionDefinition, OptionGroup } from '../internal/components/option/interfaces'; +import { isGroup } from '../internal/components/option/utils/filter-options'; +import { prepareOptions } from '../internal/components/option/utils/prepare-options'; +import ScreenreaderOnly from '../internal/components/screenreader-only'; +import { FormFieldValidationControlProps, useFormFieldContext } from '../internal/context/form-field-context'; +import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; +import { useUniqueId } from '../internal/hooks/use-unique-id'; +import { joinStrings } from '../internal/utils/strings'; +import PlainList, { SelectListProps } from '../select/parts/plain-list'; +import VirtualList from '../select/parts/virtual-list'; +import { checkOptionValueField } from '../select/utils/check-option-value-field.js'; +import { findOptionIndex } from '../select/utils/connect-options'; +import { useAnnouncement } from '../select/utils/use-announcement'; +import { useLoadItems } from '../select/utils/use-load-items'; +import { MenuProps, useSelect } from '../select/utils/use-select'; +import { MultiselectProps } from './interfaces'; + +import styles from './styles.css.js'; + +type EmbeddedMultiselectProps = Pick< + MultiselectProps, + | 'options' + | 'ariaLabel' + | 'noMatch' + | 'renderHighlightedAriaLive' + | 'selectedOptions' + | 'onLoadItems' + | 'onChange' + | 'virtualScroll' + | 'selectedAriaLabel' +> & + DropdownStatusProps & + FormFieldValidationControlProps & { filteringValue?: string }; + +// TODO: reuse code with internal multiselect +const EmbeddedMultiselect = React.forwardRef( + ( + { + options = [], + ariaLabel, + statusType = 'finished', + empty, + loadingText, + finishedText, + errorText, + noMatch, + renderHighlightedAriaLive, + selectedOptions = [], + onLoadItems, + onChange, + virtualScroll, + filteringValue = '', + ...restProps + }: EmbeddedMultiselectProps, + externalRef: React.Ref + ) => { + checkOptionValueField('Multiselect', 'options', options); + + const formFieldContext = useFormFieldContext(restProps); + + const i18nCommon = useInternalI18n('select'); + const recoveryText = i18nCommon('recoveryText', restProps.recoveryText); + const errorIconAriaLabel = i18nCommon('errorIconAriaLabel', restProps.errorIconAriaLabel); + const selectedAriaLabel = i18nCommon('selectedAriaLabel', restProps.selectedAriaLabel); + + if (restProps.recoveryText && !onLoadItems) { + warnOnce('Multiselect', '`onLoadItems` must be provided for `recoveryText` to be displayed.'); + } + + const { handleLoadMore, handleRecoveryClick, fireLoadItems } = useLoadItems({ + onLoadItems, + options, + statusType, + }); + const useInteractiveGroups = true; + + const { filteredOptions, parentMap } = prepareOptions(options, 'auto', filteringValue); + + const updateSelectedOption = useCallback( + (option: OptionDefinition | OptionGroup) => { + const filtered = filteredOptions.filter(item => item.type !== 'parent').map(item => item.option); + + // switch between selection and deselection behavior, ignores disabled options to prevent + // getting stuck on one behavior when an option is disabled and its state cannot be changed + const isAllChildrenSelected = (optionsArray: OptionDefinition[]) => + optionsArray.every(item => findOptionIndex(selectedOptions, item) > -1 || item.disabled); + const intersection = (visibleOptions: OptionDefinition[], options: OptionDefinition[]) => + visibleOptions.filter(item => findOptionIndex(options, item) > -1 && !item.disabled); + const union = (visibleOptions: OptionDefinition[], options: OptionDefinition[]) => + visibleOptions.filter(item => findOptionIndex(options, item) === -1).concat(options); + const select = (options: OptionDefinition[], selectedOptions: OptionDefinition[]) => { + return union(selectedOptions, options); + }; + const unselect = (options: OptionDefinition[], selectedOptions: OptionDefinition[]) => { + return selectedOptions.filter(option => findOptionIndex(options, option) === -1); + }; + let newSelectedOptions = [...selectedOptions]; + + if (isGroup(option)) { + const visibleOptions = intersection([...option.options], filtered); + newSelectedOptions = isAllChildrenSelected(visibleOptions) + ? unselect(visibleOptions, newSelectedOptions) + : select(visibleOptions, newSelectedOptions); + } else { + newSelectedOptions = isAllChildrenSelected([option]) + ? unselect([option], newSelectedOptions) + : select([option], newSelectedOptions); + } + + fireNonCancelableEvent(onChange, { + selectedOptions: newSelectedOptions, + }); + }, + [onChange, selectedOptions, filteredOptions] + ); + + const selfControlId = useUniqueId('trigger'); + const controlId = formFieldContext.controlId ?? selfControlId; + + const multiSelectAriaLabelId = useUniqueId('multiselect-arialabel-'); + + const footerId = useUniqueId('footer'); + + const scrollToIndex = useRef(null); + const { + highlightType, + highlightedOption, + highlightedIndex, + getMenuProps, + getOptionProps, + announceSelected, + getFilterProps, + } = useSelect({ + selectedOptions, + updateSelectedOption, + options: filteredOptions, + filteringType: 'auto', + externalRef, + fireLoadItems, + setFilteringValue: () => {}, + useInteractiveGroups, + statusType, + }); + + const isEmpty = !options || options.length === 0; + const isNoMatch = filteredOptions && filteredOptions.length === 0; + const dropdownStatus = useDropdownStatus({ + statusType, + empty, + loadingText, + finishedText, + errorText, + recoveryText, + isEmpty, + isNoMatch, + noMatch, + onRecoveryClick: handleRecoveryClick, + errorIconAriaLabel: errorIconAriaLabel, + hasRecoveryCallback: !!onLoadItems, + }); + + const menuProps: MenuProps = { + ...getMenuProps(), + onLoadMore: handleLoadMore, + ariaLabelledby: joinStrings(multiSelectAriaLabelId, controlId), + ariaDescribedby: dropdownStatus.content ? footerId : undefined, + }; + menuProps.open = true; + + const announcement = useAnnouncement({ + announceSelected, + highlightedOption, + getParent: option => parentMap.get(option)?.option as undefined | OptionGroup, + selectedAriaLabel, + renderHighlightedAriaLive, + }); + + useEffect(() => { + scrollToIndex.current?.(highlightedIndex); + }, [highlightedIndex]); + + const ListComponent = virtualScroll ? VirtualList : PlainList; + + const filterProps = getFilterProps(); + + // TODO: give list wrapper a role + return ( +
filterProps.onKeyDown && fireKeyboardEvent(filterProps.onKeyDown, event)} + > + : null} + menuProps={menuProps} + getOptionProps={getOptionProps} + filteredOptions={filteredOptions} + filteringValue={filteringValue} + ref={scrollToIndex} + hasDropdownStatus={dropdownStatus.content !== null} + checkboxes={true} + useInteractiveGroups={useInteractiveGroups} + screenReaderContent={announcement} + highlightType={highlightType} + /> + + {ariaLabel} +
+ ); + } +); + +export default EmbeddedMultiselect; diff --git a/src/multiselect/styles.scss b/src/multiselect/styles.scss index ec6155b702..3452db7a0c 100644 --- a/src/multiselect/styles.scss +++ b/src/multiselect/styles.scss @@ -5,11 +5,24 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; .root { @include styles.styles-reset; } +.embedded { + @include styles.styles-reset; + + &:focus { + outline: none; + } + + @include focus-visible.when-visible { + @include styles.focus-highlight(awsui.$space-xxs); + } +} + .tokens { margin-block-start: awsui.$space-scaled-xs; } diff --git a/src/property-filter/__tests__/common.tsx b/src/property-filter/__tests__/common.tsx index ad997a9ab1..04529c2317 100644 --- a/src/property-filter/__tests__/common.tsx +++ b/src/property-filter/__tests__/common.tsx @@ -124,6 +124,7 @@ export function toInternalProperties(properties: FilteringProperty[]): InternalF propertyGroup: property.group, operators: (property.operators ?? []).map(op => (typeof op === 'string' ? op : op.operator)), defaultOperator: property.defaultOperator ?? '=', + getValueType: () => 'auto', getValueFormatter: () => null, getValueFormRenderer: () => null, externalProperty: property, diff --git a/src/property-filter/i18n-utils.ts b/src/property-filter/i18n-utils.ts index 4a53bce2d6..8654b897cb 100644 --- a/src/property-filter/i18n-utils.ts +++ b/src/property-filter/i18n-utils.ts @@ -64,7 +64,10 @@ export function usePropertyFilterI18n(def: I18nStrings = {}): I18nStringsInterna ) ?? (token => `${token.propertyLabel} ${token.operator} ${token.value}`); function toFormatted(token: InternalToken): FormattedToken { - const valueFormatter = token.property?.getValueFormatter(token.operator); + let valueFormatter = token.property?.getValueFormatter(token.operator); + if (!valueFormatter && token.property?.getValueType(token.operator) === 'enum') { + valueFormatter = value => (Array.isArray(value) ? value.join(', ') : value); + } const propertyLabel = token.property ? token.property.propertyLabel : allPropertiesLabel ?? ''; const tokenValue = valueFormatter ? valueFormatter(token.value) : token.value; return { propertyKey: token.property?.propertyKey, propertyLabel, operator: token.operator, value: tokenValue }; diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index fec612f982..cd8ca77e95 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -16,6 +16,7 @@ import { PropertyFilterQuery, PropertyFilterToken, PropertyFilterTokenGroup, + PropertyFilterValueType, } from '@cloudscape-design/collection-hooks'; import { AutosuggestProps } from '../autosuggest/interfaces'; @@ -346,6 +347,7 @@ export type ExtendedOperatorForm = PropertyFilterOperatorForm = PropertyFilterOperatorFormat; export type FilteringOption = PropertyFilterProps.FilteringOption; export type FilteringProperty = PropertyFilterProps.FilteringProperty; +export type FilteringPropertyValueType = PropertyFilterValueType; export type Query = PropertyFilterProps.Query; export type LoadItemsDetail = PropertyFilterProps.LoadItemsDetail; export type I18nStrings = PropertyFilterProps.I18nStrings; @@ -364,6 +366,7 @@ export interface InternalFilteringProperty { propertyGroup?: string; operators: readonly PropertyFilterOperator[]; defaultOperator: PropertyFilterOperator; + getValueType: (operator?: PropertyFilterOperator) => FilteringPropertyValueType; getValueFormatter: (operator?: PropertyFilterOperator) => null | ((value: any) => string); getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorForm; // Original property used in callbacks. diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 8cda4ad405..d2840d283f 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -38,7 +38,7 @@ import { Token, TokenGroup, } from './interfaces'; -import { PropertyEditor } from './property-editor'; +import { PropertyEditorCommonProps, PropertyEditorCustom, PropertyEditorEnum } from './property-editor'; import PropertyFilterAutosuggest, { PropertyFilterAutosuggestProps } from './property-filter-autosuggest'; import { TokenButton } from './token'; import { useLoadItems } from './use-load-items'; @@ -129,6 +129,7 @@ const PropertyFilterInternal = React.forwardRef( propertyGroup: property?.group, operators: (property?.operators ?? []).map(op => (typeof op === 'string' ? op : op.operator)), defaultOperator: property?.defaultOperator ?? '=', + getValueType: operator => (operator ? extendedOperators.get(operator)?.valueType ?? 'auto' : 'auto'), getValueFormatter: operator => (operator ? extendedOperators.get(operator)?.format ?? null : null), getValueFormRenderer: operator => (operator ? extendedOperators.get(operator)?.form ?? null : null), externalProperty: property, @@ -301,6 +302,30 @@ const PropertyFilterInternal = React.forwardRef( const operatorForm = parsedText.step === 'property' && parsedText.property.getValueFormRenderer(parsedText.operator); + const isEnumValue = + parsedText.step === 'property' && parsedText.property.getValueType(parsedText.operator) === 'enum'; + + const customFormProps: null | PropertyEditorCommonProps = + parsedText.step === 'property' + ? { + property: parsedText.property, + operator: parsedText.operator, + filter: parsedText.value, + i18nStrings, + onCancel: () => { + setFilteringText(''); + inputRef.current?.close(); + inputRef.current?.focus({ preventDropdown: true }); + }, + onSubmit: token => { + addToken(token); + setFilteringText(''); + inputRef.current?.focus({ preventDropdown: true }); + inputRef.current?.close(); + }, + } + : null; + const searchResultsId = useUniqueId('property-filter-search-results'); const constraintTextId = useUniqueId('property-filter-constraint'); const textboxAriaDescribedBy = filteringConstraintText @@ -329,26 +354,16 @@ const PropertyFilterInternal = React.forwardRef( expandToViewport={expandToViewport} onOptionClick={handleSelected} customForm={ - operatorForm && ( - { - setFilteringText(''); - inputRef.current?.close(); - inputRef.current?.focus({ preventDropdown: true }); - }} - onSubmit={token => { - addToken(token); - setFilteringText(''); - inputRef.current?.focus({ preventDropdown: true }); - inputRef.current?.close(); - }} + operatorForm && customFormProps ? ( + + ) : isEnumValue && customFormProps ? ( + - ) + ) : null } hideEnteredTextOption={internalFreeText.disabled && parsedText.step !== 'property'} clearAriaLabel={i18nStrings.clearAriaLabel} diff --git a/src/property-filter/property-editor.tsx b/src/property-filter/property-editor.tsx index 684bae17f0..f079c023ee 100644 --- a/src/property-filter/property-editor.tsx +++ b/src/property-filter/property-editor.tsx @@ -5,38 +5,103 @@ import React, { useState } from 'react'; import InternalButton from '../button/internal'; import InternalFormField from '../form-field/internal'; +import { DropdownStatusProps } from '../internal/components/dropdown-status'; +import { NonCancelableEventHandler } from '../internal/events'; +import EmbeddedMultiselect from '../multiselect/embedded'; import { I18nStringsInternal } from './i18n-utils'; -import { ComparisonOperator, ExtendedOperatorForm, InternalFilteringProperty, InternalToken } from './interfaces'; +import { + ComparisonOperator, + ExtendedOperatorForm, + InternalFilteringOption, + InternalFilteringProperty, + InternalToken, + LoadItemsDetail, +} from './interfaces'; +import { useLoadItems } from './use-load-items'; import styles from './styles.css.js'; -interface PropertyEditorProps { +export interface PropertyEditorCommonProps { property: InternalFilteringProperty; operator: ComparisonOperator; filter: string; - operatorForm: ExtendedOperatorForm; onCancel: () => void; onSubmit: (value: InternalToken) => void; i18nStrings: I18nStringsInternal; } -export function PropertyEditor({ - property, +export function PropertyEditorCustom({ + operatorForm, operator, filter, - operatorForm, + ...props +}: PropertyEditorCommonProps & { operatorForm: ExtendedOperatorForm }) { + return ( + {...props} operator={operator}> + {(value, onChange) => operatorForm({ value, onChange, operator, filter })} + + ); +} + +export function PropertyEditorEnum({ + property, + filter, + asyncProps, + filteringOptions, + onLoadItems, + ...props +}: PropertyEditorCommonProps & { + asyncProps: DropdownStatusProps; + filteringOptions: readonly InternalFilteringOption[]; + onLoadItems?: NonCancelableEventHandler; +}) { + const valueOptions = property + ? filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })) + : []; + + const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property?.externalProperty); + const asyncValueAutosuggestProps = property?.propertyKey + ? { ...valueAutosuggestHandlers, ...asyncProps } + : { empty: asyncProps.empty }; + + return ( + {...props} property={property}> + {(unknownValue, onChange) => { + const value = !unknownValue ? [] : Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + return ( + onChange(e.detail.selectedOptions.map(o => o.value!))} + options={valueOptions} + filteringValue={filter} + {...asyncValueAutosuggestProps} + virtualScroll={true} + /> + ); + }} + + ); +} + +function PropertyEditorForm({ + property, + operator, + children, onCancel, onSubmit, i18nStrings, -}: PropertyEditorProps) { +}: Omit & { + children: (value: null | TokenValue, onChange: (value: null | TokenValue) => void) => React.ReactNode; +}) { const [value, onChange] = useState(null); const submitToken = () => onSubmit({ property, operator, value }); return (
- - {operatorForm({ value, onChange, operator, filter })} - + {children(value, onChange)}
diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 257d1940fd..adcab22e1c 100644 --- a/src/property-filter/token-editor-inputs.tsx +++ b/src/property-filter/token-editor-inputs.tsx @@ -6,6 +6,7 @@ import React from 'react'; import InternalAutosuggest from '../autosuggest/internal.js'; import { DropdownStatusProps } from '../internal/components/dropdown-status/interfaces.js'; import { NonCancelableEventHandler } from '../internal/events/index.js'; +import InternalMultiselect from '../multiselect/internal.js'; import { SelectProps } from '../select/interfaces.js'; import InternalSelect from '../select/internal.js'; import { getAllowedOperators, getPropertySuggestions } from './controller.js'; @@ -122,14 +123,27 @@ interface ValueInputProps { asyncProps: DropdownStatusProps; filteringOptions: readonly InternalFilteringOption[]; i18nStrings: I18nStringsInternal; - onChangeValue: (value: string) => void; + onChangeValue: (value: unknown) => void; onLoadItems?: NonCancelableEventHandler; operator: undefined | ComparisonOperator; property: null | InternalFilteringProperty; - value: undefined | string; + value: unknown; } -export function ValueInput({ +export function ValueInput(props: ValueInputProps) { + const { property, operator, value, onChangeValue } = props; + const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); + + if (OperatorForm) { + return ; + } + if (property?.getValueType(operator) === 'enum') { + return ; + } + return ; +} + +function ValueInputAuto({ property, operator, value, @@ -151,11 +165,7 @@ export function ValueInput({ : { empty: asyncProps.empty }; const [matchedOption] = valueOptions.filter(option => option.value === value); - const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); - - return OperatorForm ? ( - - ) : ( + return ( ); } + +function ValueInputEnum({ + property, + operator, + value: unknownValue, + onChangeValue, + asyncProps, + filteringOptions, + onLoadItems, +}: ValueInputProps) { + const valueOptions = property + ? filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })) + : []; + + const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property?.externalProperty); + const asyncValueAutosuggestProps = property?.propertyKey + ? { ...valueAutosuggestHandlers, ...asyncProps } + : { empty: asyncProps.empty }; + + const value = Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + + return ( + onChangeValue(e.detail.selectedOptions.map(o => o.value))} + disabled={!operator} + options={valueOptions} + {...asyncValueAutosuggestProps} + virtualScroll={true} + inlineTokens={true} + /> + ); +}