diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7b449ba94..aa9b71e48 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "foxy-elements", "image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm", "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { "packages": "chromium" } diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 000000000..6a115a9f2 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,41 @@ +name: Create release PR (beta β†’ main) + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (print output, don't create/update PR)" + required: false + default: "false" + +permissions: + contents: read + pull-requests: write + +jobs: + release_pr: + runs-on: ubuntu-latest + + steps: + - name: Checkout (full history + tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Create/Update release PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_BRANCH: main + HEAD_BRANCH: beta + REVIEWER: brettflorio + UPDATE_IF_EXISTS: "true" + run: | + set -euo pipefail + chmod +x scripts/release-pr.sh + + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + ./scripts/release-pr.sh --dry-run + else + ./scripts/release-pr.sh + fi \ No newline at end of file diff --git a/.scripts/release-pr.sh b/.scripts/release-pr.sh new file mode 100755 index 000000000..6414ecd86 --- /dev/null +++ b/.scripts/release-pr.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ----------------- CONFIG ----------------- +BASE_BRANCH="${BASE_BRANCH:-main}" +HEAD_BRANCH="${HEAD_BRANCH:-beta}" +REMOTE="${REMOTE:-origin}" + +REVIEWER="${REVIEWER:-brettflorio}" +UPDATE_IF_EXISTS="${UPDATE_IF_EXISTS:-true}" +# ------------------------------------------ + +usage() { + cat < BASE, with body built from conventional commits in BASE..HEAD. +- Conventional commits only +- Merge commits omitted +- Grouped by type (Features, Bug Fixes, etc.) +- PR title derived from latest beta tag on HEAD: vX.Y.Z-beta.N -> "chore: release X.Y.Z" + +Flags: + -n, --dry-run Print what would be created/updated, but do not call GitHub. +EOF +} + +DRY_RUN="false" +if [[ "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN="true" +elif [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +elif [[ -n "${1:-}" ]]; then + echo "Unknown argument: $1" >&2 + usage + exit 2 +fi + +require() { command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1" >&2; exit 1; }; } +require git +require gh +require python3 +require grep +require sed +require sort +require tail +require tr + +git rev-parse --is-inside-work-tree >/dev/null + +# In dry-run we still need to know repo, refs, tags; we can do without auth, but it’s fine to keep. +gh auth status >/dev/null 2>&1 || { + if [[ "$DRY_RUN" == "false" ]]; then + echo "GitHub CLI not authenticated. Run: gh auth login" >&2 + exit 1 + fi +} + +git fetch "$REMOTE" "$BASE_BRANCH" "$HEAD_BRANCH" --tags --prune >/dev/null + +OWNER_REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)" +if [[ -z "$OWNER_REPO" ]]; then + # Fallback if gh repo view fails in dry-run environments + OWNER_REPO="$(git config --get remote.${REMOTE}.url | sed -E 's#(git@|https://)github.com[:/](.+)\.git#\2#')" +fi +OWNER="${OWNER_REPO%/*}" +REPO="${OWNER_REPO#*/}" + +RANGE="$REMOTE/$BASE_BRANCH..$REMOTE/$HEAD_BRANCH" + +# Conventional commit subject pattern (type, optional scope, optional !, colon-space, message) +CONVENTIONAL_RE='^(feat|fix|perf|refactor|revert|docs|style|test|build|ci|chore)(\([^)]+\))?(!)?: .+' + +RAW_SUBJECTS="$(git log --no-merges --pretty=format:'%s' "$RANGE" || true)" +FILTERED="$(echo "$RAW_SUBJECTS" | grep -E "$CONVENTIONAL_RE" || true)" + +if [[ -z "${FILTERED//[$'\n'' ''\t']/}" ]]; then + echo "No conventional commits found in $RANGE (after excluding merges)." >&2 + exit 1 +fi + +section_title() { + case "$1" in + feat) echo "Features" ;; + fix) echo "Bug Fixes" ;; + perf) echo "Performance" ;; + refactor) echo "Refactoring" ;; + docs) echo "Documentation" ;; + style) echo "Styles" ;; + test) echo "Tests" ;; + build) echo "Build" ;; + ci) echo "CI" ;; + chore) echo "Chores" ;; + revert) echo "Reverts" ;; + *) echo "" ;; + esac +} + +# Render bullets: +# - scoped: **scope**: message +# - unscoped: message +format_lines_for_type() { + local type="$1" + local lines + lines="$(echo "$FILTERED" | grep -E "^${type}(\([^)]+\))?(!)?: " || true)" + [[ -z "${lines//[$'\n'' ''\t']/}" ]] && return 0 + + # 1) type(scope)!: msg -> - **scope**: msg + # 2) type(scope): msg -> - **scope**: msg + # 3) type!: msg -> - msg + # 4) type: msg -> - msg + echo "$lines" | sed -E \ + -e "s/^${type}\(([^)]+)\)(!)?: (.+)$/- **\1**: \3/" \ + -e "s/^${type}(!)?: (.+)$/- \2/" +} + +make_section() { + local type="$1" + local title; title="$(section_title "$type")" + [[ -z "$title" ]] && return 0 + + local bullets + bullets="$(format_lines_for_type "$type" || true)" + [[ -z "${bullets//[$'\n'' ''\t']/}" ]] && return 0 + + echo "### $title" + echo "$bullets" + echo +} + +BODY="$( + make_section feat + make_section fix + make_section perf + make_section refactor + make_section docs + make_section style + make_section test + make_section build + make_section ci + make_section chore + make_section revert +)" + +# Derive release version from latest beta tag on HEAD: +# v1.2.3-beta.4 -> 1.2.3 +derive_release_version() { + # Prefer tags that are reachable from HEAD_BRANCH (beta) + local latest_beta_tag + latest_beta_tag="$( + git tag --list 'v*-beta.*' --merged "$REMOTE/$HEAD_BRANCH" \ + | sort -V \ + | tail -n 1 \ + || true + )" + + if [[ -n "$latest_beta_tag" ]]; then + echo "$latest_beta_tag" \ + | sed -E 's/^v//' \ + | sed -E 's/-beta\.[0-9]+$//' + return 0 + fi + + # Fallback: try package.json version + if [[ -f package.json ]]; then + local v + v="$(node -p "require('./package.json').version" 2>/dev/null || true)" + if [[ -n "$v" ]]; then + echo "$v" + return 0 + fi + fi + + # Last resort: empty + echo "" +} + +RELEASE_VERSION="$(derive_release_version)" +if [[ -z "$RELEASE_VERSION" ]]; then + echo "Could not derive release version (no beta tags like vX.Y.Z-beta.N found on $REMOTE/$HEAD_BRANCH, and no package.json version)." >&2 + exit 1 +fi + +TITLE="chore: release ${RELEASE_VERSION}" + +# --------- Find existing open PR (HEAD -> BASE) via GitHub API ---------- +find_existing_pr() { + gh api "repos/${OWNER}/${REPO}/pulls?state=open&base=${BASE_BRANCH}&head=${OWNER}:${HEAD_BRANCH}" 2>/dev/null \ + | python3 - <<'PY' +import sys, json +try: + prs = json.load(sys.stdin) +except Exception: + prs = [] +if prs: + print(prs[0].get("number","")) + print(prs[0].get("html_url","")) +PY +} + +if [[ "$DRY_RUN" == "true" ]]; then + echo "DRY RUN" + echo "Base: $BASE_BRANCH" + echo "Head: $HEAD_BRANCH" + echo "Reviewer: $REVIEWER" + echo "Title: $TITLE" + echo "Body:" + echo "----------------------------------------" + echo "$BODY" + echo "----------------------------------------" + exit 0 +fi + +if [[ "$UPDATE_IF_EXISTS" == "true" ]]; then + existing="$(find_existing_pr || true)" + existing_number="$(echo "$existing" | sed -n '1p')" + existing_url="$(echo "$existing" | sed -n '2p')" + + if [[ -n "${existing_number}" ]]; then + echo "Found existing PR: ${existing_url:-#${existing_number}}" + gh pr edit "$existing_number" --title "$TITLE" --body "$BODY" >/dev/null + gh pr edit "$existing_number" --add-reviewer "$REVIEWER" >/dev/null || true + echo "βœ… Updated PR and requested reviewer: $REVIEWER" + echo "${existing_url:-https://github.com/${OWNER_REPO}/pull/${existing_number}}" + exit 0 + fi +fi + +# --------- Create PR ---------- +PR_URL="$( + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$HEAD_BRANCH" \ + --title "$TITLE" \ + --body "$BODY" \ + --reviewer "$REVIEWER" \ + | tail -n 1 +)" + +echo "βœ… Created PR and requested reviewer: $REVIEWER" +echo "$PR_URL" \ No newline at end of file diff --git a/custom-elements.json b/custom-elements.json index fc5cd2d2c..6c3e41fd1 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -13705,6 +13705,12 @@ "path": "./src/elements/public/FilterAttributeForm/index.ts", "description": "Form element for creating and editing saved filters in Admin. Saved filters\nare powered by the Bookmark attribute format that allows adding custom sidebar items\nto Admin. Bookmark attributes are named `foxy-admin-bookmark` and contain a\nrelative URL of the bookmarked Admin page in the value.", "attributes": [ + { + "name": "operators", + "description": "List of operators passed down to `QueryBuilder.operators`.", + "type": "array", + "default": "\"Object.values(Operator)\"" + }, { "name": "defaults", "description": "Default filter query." @@ -13830,6 +13836,13 @@ "type": "string", "default": "\"filter_name\"" }, + { + "name": "operators", + "attribute": "operators", + "description": "List of operators passed down to `QueryBuilder.operators`.", + "type": "array", + "default": "\"Object.values(Operator)\"" + }, { "name": "defaults", "attribute": "defaults", @@ -23198,7 +23211,7 @@ }, { "name": "operators", - "description": "List of operators available in the builder UI.", + "description": "List of operators available in the builder UI. When an operator is an object, it specifies the operator type and the paths it applies to.", "type": "array", "default": "\"Object.values(Operator)\"" }, @@ -23310,7 +23323,7 @@ { "name": "operators", "attribute": "operators", - "description": "List of operators available in the builder UI.", + "description": "List of operators available in the builder UI. When an operator is an object, it specifies the operator type and the paths it applies to.", "type": "array", "default": "\"Object.values(Operator)\"" }, diff --git a/package-lock.json b/package-lock.json index af25d0e66..2a1a644a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@foxy.io/sdk": "^1.16.1", + "@foxy.io/sdk": "^1.16.2", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", @@ -1822,9 +1822,9 @@ } }, "node_modules/@foxy.io/sdk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.16.1.tgz", - "integrity": "sha512-noCyCbwbrnIL7jnIWduGD9e1oPeU3TbHPl1c/8MW7ceGVZ+op+h1eB3OlxbfenxoB9Z3qTZeGlhX7w7WOOvpDg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.16.2.tgz", + "integrity": "sha512-jaBtjKIx3u9zzAXZ+5T52KC4rzuBMbGIcrN/ys0JGtQDjxIFV6rnnAvm56htN8N1TyF8T28TfLlNxo/VViBK+w==", "license": "MIT", "dependencies": { "@types/jsdom": "^16.2.5", diff --git a/package.json b/package.json index ba1d4b8d6..a5658ce8c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prepack": "npm run lint && rimraf dist && node ./.build/compile-for-npm.js && rollup -c" }, "dependencies": { - "@foxy.io/sdk": "^1.16.1", + "@foxy.io/sdk": "^1.16.2", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", diff --git a/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.test.ts b/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.test.ts deleted file mode 100644 index 498448563..000000000 --- a/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { FetchEvent } from '../../public/NucleonElement/FetchEvent'; - -import { expect, fixture } from '@open-wc/testing'; -import { html } from 'lit-html'; - -import get from 'lodash-es/get'; - -import { InternalAsyncComboBoxControl as Control } from './index'; -import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; - -class TestControl extends Control { - static get properties() { - return { - ...super.properties, - testCheckValidity: { attribute: false }, - testErrorMessage: { attribute: false }, - testValue: { attribute: false }, - }; - } - - testCheckValidity = () => true; - - testErrorMessage = ''; - - testValue = ''; - - protected get _checkValidity() { - return this.testCheckValidity; - } - - protected get _errorMessage() { - return this.testErrorMessage; - } - - protected get _value() { - return this.testValue; - } - - protected set _value(newValue: string) { - this.testValue = newValue; - super._value = newValue; - } -} - -customElements.define('test-internal-async-combo-box-control', TestControl); - -describe('InternalAsyncComboBoxControl', () => { - it('imports and defines vaadin-combo-box', () => { - expect(customElements.get('vaadin-combo-box')).to.not.be.undefined; - }); - - it('imports and defines foxy-internal-editable-control', () => { - expect(customElements.get('foxy-internal-editable-control')).to.equal(InternalEditableControl); - }); - - it('imports and defines itself as foxy-internal-async-combo-box-control', () => { - expect(customElements.get('foxy-internal-async-combo-box-control')).to.equal(Control); - }); - - it('extends InternalEditableControl', () => { - expect(new Control()).to.be.instanceOf(InternalEditableControl); - }); - - it('defines a reactive property for "itemLabelPath" ("item-label-path", String)', () => { - expect(Control).to.have.nested.property('properties.itemLabelPath.type', String); - expect(Control).to.have.nested.property( - 'properties.itemLabelPath.attribute', - 'item-label-path' - ); - - expect(new Control()).to.have.property('itemLabelPath', null); - }); - - it('defines a reactive property for "itemValuePath" ("item-value-path", String)', () => { - expect(Control).to.have.nested.property('properties.itemValuePath.type', String); - expect(Control).to.have.nested.property( - 'properties.itemValuePath.attribute', - 'item-value-path' - ); - - expect(new Control()).to.have.property('itemValuePath', null); - }); - - it('defines a reactive property for "first" (String)', () => { - expect(Control).to.have.nested.property('properties.first.type', String); - expect(Control).to.not.have.nested.property('properties.first.attribute'); - expect(new Control()).to.have.property('first', null); - }); - - it('renders vaadin-combo-box element', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box'); - - expect(comboBox).to.not.be.null; - }); - - it('passes "itemValuePath" down to the vaadin-combo-box element', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.empty.property('itemValuePath'); - - control.itemValuePath = 'test'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('itemValuePath', 'test'); - }); - - it('passes "itemLabelPath" down to the vaadin-combo-box element', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.empty.property('itemLabelPath'); - - control.itemLabelPath = 'test'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('itemLabelPath', 'test'); - }); - - it('uses resource URI as item ID for the vaadin-combo-box element', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('itemIdPath', '_links.self.href'); - }); - - it('sets "errorMessage" on vaadin-combo-box from "_errorMessage" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('errorMessage', ''); - - control.testErrorMessage = 'test error message'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('errorMessage', 'test error message'); - }); - - it('sets "helperText" on vaadin-combo-box from "helperText" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('helperText', 'helper_text'); - - control.helperText = 'test helper text'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('helperText', 'test helper text'); - }); - - it('sets "placeholder" on vaadin-combo-box from "placeholder" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('placeholder', 'placeholder'); - - control.placeholder = 'test placeholder'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('placeholder', 'test placeholder'); - }); - - it('sets "label" on vaadin-combo-box from "label" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('label', 'label'); - - control.label = 'test label'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('label', 'test label'); - }); - - it('sets "disabled" on vaadin-combo-box from "disabled" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - control.disabled = true; - await control.requestUpdate(); - expect(comboBox).to.have.property('disabled', true); - - control.disabled = false; - await control.requestUpdate(); - expect(comboBox).to.have.property('disabled', false); - }); - - it('sets "readonly" on vaadin-combo-box from "readonly" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - control.readonly = true; - await control.requestUpdate(); - expect(comboBox).to.have.property('readonly', true); - - control.readonly = false; - await control.requestUpdate(); - expect(comboBox).to.have.property('readonly', false); - }); - - it('sets "checkValidity" on vaadin-combo-box from "_checkValidity" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('checkValidity', get(control, '_checkValidity')); - }); - - it('loads items from the "first" URL', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - const dataProvider = comboBox.dataProvider!; - - control.itemLabelPath = 'foo'; - control.first = 'https://demo.api/test/'; - await control.requestUpdate(); - - const testItems = [{ foo: 'bar1' }, { foo: 'bar2' }]; - const testSize = 10; - - control.addEventListener( - 'fetch', - (evt: Event) => { - const url = new URL((evt as FetchEvent).request.url); - - expect(url.origin).to.equal('https://demo.api'); - expect(url.pathname).to.equal('/test/'); - expect(url.searchParams.get('offset')).to.equal('2'); - expect(url.searchParams.get('limit')).to.equal('2'); - expect(url.searchParams.get('foo')).to.equal('*3*'); - - const body = { total_items: testSize, _embedded: { testItems } }; - const response = new Response(JSON.stringify(body)); - - (evt as FetchEvent).respondWith(Promise.resolve(response)); - }, - { once: true } - ); - - await new Promise(resolve => { - dataProvider({ page: 1, pageSize: 2, filter: '3' }, (items, size) => { - expect(items).to.deep.equal(testItems); - expect(size).to.equal(testSize); - - resolve(); - }); - }); - }); - - it('sets "value" on vaadin-combo-box from "_value" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('value', ''); - - control.testValue = 'test_value'; - await control.requestUpdate(); - - expect(comboBox).to.have.property('value', 'test_value'); - }); - - it('writes to "_value" on change', async () => { - const layout = html``; - const control = await fixture(layout); - const comboBox = control.renderRoot.querySelector('vaadin-combo-box')!; - - expect(comboBox).to.have.property('value', ''); - - comboBox.value = 'test_value'; - comboBox.dispatchEvent(new CustomEvent('change')); - - expect(control).to.have.property('testValue', 'test_value'); - }); -}); diff --git a/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.ts b/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.ts deleted file mode 100644 index dc15dc59c..000000000 --- a/src/elements/internal/InternalAsyncComboBoxControl/InternalAsyncComboBoxControl.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { ComboBoxDataProvider, ComboBoxElement, ComboBoxItem } from '@vaadin/vaadin-combo-box'; -import type { TemplateResult } from 'lit-html'; - -import { PropertyDeclarations } from 'lit-element'; -import { ifDefined } from 'lit-html/directives/if-defined'; -import { html } from 'lit-html'; - -import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; -import { API } from '../../public/NucleonElement/API'; - -/** - * Internal control displaying a combo box where items are loaded from - * a hAPI collection. - * - * @since 1.17.0 - * @element foxy-internal-async-combo-box-control - */ -export class InternalAsyncComboBoxControl extends InternalEditableControl { - static get properties(): PropertyDeclarations { - return { - ...super.properties, - allowCustomValue: { type: Boolean, attribute: 'allow-custom-value' }, - itemLabelPath: { type: String, attribute: 'item-label-path' }, - itemValuePath: { type: String, attribute: 'item-value-path' }, - selectedItem: { attribute: false }, - first: { type: String }, - }; - } - - /** Same as `allowCustomValue` property of Vaadin's `ComboBoxElement`. */ - allowCustomValue = false; - - /** Same as `itemLabelPath` property of Vaadin's `ComboBoxElement`. */ - itemLabelPath: string | null = null; - - /** Same as `itemValuePath` property of Vaadin's `ComboBoxElement`. */ - itemValuePath: string | null = null; - - selectedItem: ComboBoxItem | string | undefined = undefined; - - /** URL of the first page of the hAPI collection serving as a source for items. */ - first: string | null = null; - - private __dataProvider: ComboBoxDataProvider = async (params, callback) => { - if (!this.first) return callback([], 0); - - const url = new URL(this.first); - url.searchParams.set('offset', String(params.page * params.pageSize)); - url.searchParams.set('limit', String(params.pageSize)); - - if (params.filter && this.itemLabelPath) { - url.searchParams.set(this.itemLabelPath, `*${params.filter}*`); - } - - const response = await new API(this).fetch(url.toString()); - if (!response.ok) throw new Error(await response.text()); - - const json = await response.json(); - const items = Array.from(Object.values(json._embedded))[0] as unknown[]; - - callback(items, json.total_items); - }; - - renderControl(): TemplateResult { - return html` - { - evt.stopPropagation(); - - const comboBox = evt.currentTarget as ComboBoxElement; - this._value = comboBox.value; - - if (this._value === comboBox.value) { - this.selectedItem = comboBox.selectedItem; - this.dispatchEvent(new CustomEvent('selected-item-changed')); - } else { - comboBox.value = this._value; - } - }} - > - - `; - } - - updated(changes: Map): void { - super.updated(changes); - - if (changes.has('first') || changes.has('itemLabelPath')) { - const comboBox = this.renderRoot.querySelector('vaadin-combo-box'); - - // this forces reload - if (comboBox) { - comboBox.size = 0; - comboBox.size = 1; - } - } - } - - protected get _value(): string { - return (super._value as string | undefined) ?? ''; - } - - protected set _value(newValue: string) { - super._value = newValue as unknown | undefined; - } -} diff --git a/src/elements/internal/InternalAsyncComboBoxControl/index.ts b/src/elements/internal/InternalAsyncComboBoxControl/index.ts deleted file mode 100644 index c61ccd0ac..000000000 --- a/src/elements/internal/InternalAsyncComboBoxControl/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@vaadin/vaadin-combo-box'; -import '../InternalEditableControl/index'; -import { InternalAsyncComboBoxControl as Control } from './InternalAsyncComboBoxControl'; - -customElements.define('foxy-internal-async-combo-box-control', Control); - -export { Control as InternalAsyncComboBoxControl }; diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts index f6e30cdbf..a21651ef8 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts @@ -7,6 +7,7 @@ import { getTestData } from '../../../testgen/getTestData'; import { InternalEditableControl } from './index'; import { InternalControl } from '../InternalControl/InternalControl'; import { AddressForm } from '../../public/AddressForm/AddressForm'; +import { stub, match } from 'sinon'; import set from 'lodash-es/set'; @@ -350,4 +351,41 @@ describe('InternalEditableControl', () => { timeout: 5000, }); }); + + it('registers click event listener on connect', async () => { + const control = new InternalEditableControl(); + const addEventListenerStub = stub(control, 'addEventListener'); + + control.connectedCallback?.(); + + expect(addEventListenerStub).to.have.been.calledWith('click', match.func); + + addEventListenerStub.restore(); + }); + + it('removes click event listener on disconnect', async () => { + const control = new InternalEditableControl(); + const removeEventListenerStub = stub(control, 'removeEventListener'); + + control.disconnectedCallback?.(); + + expect(removeEventListenerStub).to.have.been.calledWith('click', match.func); + + removeEventListenerStub.restore(); + }); + + it('calls _handleHostClick when click event is fired', async () => { + const control = await fixture(html` + + `); + + const handleHostClickStub = stub(control, '_handleHostClick' as any); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + control.dispatchEvent(mockEvent); + + expect(handleHostClickStub).to.have.been.calledOnce; + + handleHostClickStub.restore(); + }); }); diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts index ae39e5de9..78bd8d23f 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts @@ -90,6 +90,10 @@ export class InternalEditableControl extends InternalControl { this.__asyncError = validOrError === true ? null : validOrError ?? null; }, 300); + private __handleHostClick = (evt: MouseEvent) => { + return this._handleHostClick(evt); + }; + private __previousValue: unknown | null = null; private __placeholder: string | null = null; @@ -239,6 +243,20 @@ export class InternalEditableControl extends InternalControl { } while (walker.nextNode()); } + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('click', this.__handleHostClick); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('click', this.__handleHostClick); + } + + protected _handleHostClick(evt: MouseEvent): void { + // Override in subclasses. + } + /** * A shortcut to get the inferred value from the NucleonElement instance * up the DOM tree. If no such value or instance exists, returns `undefined`. diff --git a/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.test.ts b/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.test.ts index 43a6d397a..eaa691382 100644 --- a/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.test.ts +++ b/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.test.ts @@ -463,4 +463,98 @@ describe('InternalFrequencyControl', () => { expect(input).to.have.property('value', '2'); expect(select).to.have.property('value', 'times_a_month'); }); + + it('focuses input when clicking outside INPUT and LABEL in summary-item layout', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input'); + if (!input) return; // Skip if input not found + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.have.been.calledOnce; + + focusStub.restore(); + }); + + it('does not focus input when layout is not summary-item', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('input'); + const focusStub = input ? stub(input, 'focus') : null; + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + if (focusStub) { + expect(focusStub).to.not.have.been.called; + focusStub.restore(); + } + }); + + it('does not focus input when clicking on INPUT element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [input], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); + + it('does not focus input when clicking on LABEL element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); }); diff --git a/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.ts b/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.ts index 661d5ff15..af98f686c 100644 --- a/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.ts +++ b/src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.ts @@ -154,6 +154,16 @@ export class InternalFrequencyControl extends InternalEditableControl { if (field && field.value !== this._value) field.value = (this._value ?? '') as string; } + protected _handleHostClick(evt: MouseEvent): void { + if (this.layout !== 'summary-item') return; + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['INPUT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('input')?.focus(); + super._handleHostClick(evt); + } + } + private __renderSummaryItemLayout() { const value = (this._value ?? '') as string; const [strCount, units] = this.__i18n.parseValue(value); diff --git a/src/elements/internal/InternalIntegerControl/InternalIntegerControl.test.ts b/src/elements/internal/InternalIntegerControl/InternalIntegerControl.test.ts deleted file mode 100644 index 7f1957174..000000000 --- a/src/elements/internal/InternalIntegerControl/InternalIntegerControl.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { expect, fixture } from '@open-wc/testing'; -import { html } from 'lit-html'; - -import get from 'lodash-es/get'; - -import { InternalIntegerControl as Control } from './index'; -import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; -import { NucleonElement } from '../../public/NucleonElement/index'; -import { stub } from 'sinon'; - -class TestControl extends Control { - static get properties() { - return { - ...super.properties, - testCheckValidity: { attribute: false }, - testErrorMessage: { attribute: false }, - testValue: { attribute: false }, - }; - } - - testCheckValidity = () => true; - - testErrorMessage = ''; - - testValue = 0; - - nucleon = new NucleonElement(); - - protected get _checkValidity() { - return this.testCheckValidity; - } - - protected get _errorMessage() { - return this.testErrorMessage; - } - - protected get _value() { - return this.testValue; - } - - protected set _value(newValue: number) { - this.testValue = newValue; - super._value = newValue; - } -} - -customElements.define('test-internal-integer-control', TestControl); - -describe('InternalIntegerControl', () => { - it('imports and defines vaadin-integer-field', () => { - expect(customElements.get('vaadin-integer-field')).to.not.be.undefined; - }); - - it('imports and defines foxy-internal-editable-control', () => { - expect(customElements.get('foxy-internal-editable-control')).to.equal(InternalEditableControl); - }); - - it('imports and defines itself as foxy-internal-integer-control', () => { - expect(customElements.get('foxy-internal-integer-control')).to.equal(Control); - }); - - it('has a reactive property for "showControls"', () => { - expect(new Control()).to.have.property('showControls', false); - expect(Control).to.have.deep.nested.property('properties.showControls', { - type: Boolean, - attribute: 'show-controls', - }); - }); - - it('extends InternalEditableControl', () => { - expect(new Control()).to.be.instanceOf(InternalEditableControl); - }); - - it('renders vaadin-integer-field element', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field'); - - expect(field).to.not.be.null; - }); - - it('sets "errorMessage" on vaadin-integer-field from "_errorMessage" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('errorMessage', ''); - - control.testErrorMessage = 'test error message'; - await control.requestUpdate(); - - expect(field).to.have.property('errorMessage', 'test error message'); - }); - - it('sets "helperText" on vaadin-integer-field from "helperText" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('helperText', 'helper_text'); - - control.helperText = 'test helper text'; - await control.requestUpdate(); - - expect(field).to.have.property('helperText', 'test helper text'); - }); - - it('sets "placeholder" on vaadin-integer-field from "placeholder" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('placeholder', 'placeholder'); - - control.placeholder = 'test placeholder'; - await control.requestUpdate(); - - expect(field).to.have.property('placeholder', 'test placeholder'); - }); - - it('sets "label" on vaadin-integer-field from "label" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('label', 'label'); - - control.label = 'test label'; - await control.requestUpdate(); - - expect(field).to.have.property('label', 'test label'); - }); - - it('sets "disabled" on vaadin-integer-field from "disabled" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - control.disabled = true; - await control.requestUpdate(); - expect(field).to.have.property('disabled', true); - - control.disabled = false; - await control.requestUpdate(); - expect(field).to.have.property('disabled', false); - }); - - it('sets "readonly" on vaadin-integer-field from "readonly" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - control.readonly = true; - await control.requestUpdate(); - expect(field).to.have.property('readonly', true); - - control.readonly = false; - await control.requestUpdate(); - expect(field).to.have.property('readonly', false); - }); - - it('sets "checkValidity" on vaadin-integer-field from "_checkValidity" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('checkValidity', get(control, '_checkValidity')); - }); - - it('sets "value" on vaadin-integer-field from "_value" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('value', '0'); - - control.testValue = 42; - await control.requestUpdate(); - - expect(field).to.have.property('value', '42'); - }); - - it('sets "has-controls" on vaadin-integer-field from "showControls" on itself', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.not.have.attribute('has-controls'); - - control.showControls = true; - await control.requestUpdate(); - - expect(field).to.have.attribute('has-controls'); - }); - - it('writes to "_value" on change', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - - expect(field).to.have.property('value', '0'); - - field.value = '42'; - field.dispatchEvent(new CustomEvent('change')); - - expect(control).to.have.property('testValue', 42); - }); - - it('submits the host nucleon form on Enter', async () => { - const layout = html``; - const control = await fixture(layout); - const field = control.renderRoot.querySelector('vaadin-integer-field')!; - const submitMethod = stub(control.nucleon, 'submit'); - - field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - expect(submitMethod).to.have.been.calledOnce; - - submitMethod.restore(); - }); -}); diff --git a/src/elements/internal/InternalIntegerControl/InternalIntegerControl.ts b/src/elements/internal/InternalIntegerControl/InternalIntegerControl.ts deleted file mode 100644 index 1608040bb..000000000 --- a/src/elements/internal/InternalIntegerControl/InternalIntegerControl.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { IntegerFieldElement } from '@vaadin/vaadin-text-field/vaadin-integer-field'; -import type { PropertyDeclarations, TemplateResult } from 'lit-element'; - -import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; -import { ifDefined } from 'lit-html/directives/if-defined'; -import { html } from 'lit-element'; - -/** - * Internal control displaying a basic integer box. - * - * @since 1.17.0 - * @element foxy-internal-integer-control - */ -export class InternalIntegerControl extends InternalEditableControl { - static get properties(): PropertyDeclarations { - return { - ...super.properties, - showControls: { type: Boolean, attribute: 'show-controls' }, - prefix: {}, - suffix: {}, - min: { type: Number }, - max: { type: Number }, - }; - } - - showControls = false; - - prefix: string | null = null; - - suffix: string | null = null; - - min: number | null = null; - - max: number | null = null; - - renderControl(): TemplateResult { - return html` - evt.key === 'Enter' && this.nucleon?.submit()} - @change=${(evt: CustomEvent) => { - const field = evt.currentTarget as IntegerFieldElement; - this._value = parseInt(field.value); - }} - > - ${this.prefix ? html`
${this.prefix}
` : ''} - ${this.suffix ? html`
${this.suffix}
` : ''} -
- `; - } -} diff --git a/src/elements/internal/InternalIntegerControl/index.ts b/src/elements/internal/InternalIntegerControl/index.ts deleted file mode 100644 index f3ae9de0b..000000000 --- a/src/elements/internal/InternalIntegerControl/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@vaadin/vaadin-text-field/vaadin-integer-field'; -import '../InternalEditableControl/index'; -import { InternalIntegerControl as Control } from './InternalIntegerControl'; - -customElements.define('foxy-internal-integer-control', Control); - -export { Control as InternalIntegerControl }; diff --git a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts index d52a3add9..0a01dd892 100644 --- a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts +++ b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts @@ -342,4 +342,68 @@ describe('InternalNativeDateControl', () => { const button = control.renderRoot.querySelector('button'); expect(button).to.have.property('disabled', true); }); + + it('focuses input when clicking outside INPUT and LABEL elements', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.have.been.calledOnce; + + focusStub.restore(); + }); + + it('does not focus input when clicking on INPUT element', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [input], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); + + it('does not focus input when clicking on LABEL element', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const focusStub = stub(input, 'focus'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); }); diff --git a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts index 0b1bf4dd7..e53859034 100644 --- a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts +++ b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts @@ -112,4 +112,13 @@ export class InternalNativeDateControl extends InternalEditableControl { protected set _value(newValue: string) { super._value = newValue as unknown | undefined; } + + protected _handleHostClick(evt: MouseEvent): void { + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['INPUT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('input')?.focus(); + super._handleHostClick(evt); + } + } } diff --git a/src/elements/internal/InternalNumberControl/InternalNumberControl.test.ts b/src/elements/internal/InternalNumberControl/InternalNumberControl.test.ts index 49b38c887..665a25f09 100644 --- a/src/elements/internal/InternalNumberControl/InternalNumberControl.test.ts +++ b/src/elements/internal/InternalNumberControl/InternalNumberControl.test.ts @@ -381,4 +381,98 @@ describe('InternalNumberControl', () => { submitMethod.restore(); }); + + it('focuses input when clicking outside INPUT and LABEL in summary-item layout', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input'); + if (!input) return; // Skip if input not found + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.have.been.calledOnce; + + focusStub.restore(); + }); + + it('does not focus input when layout is not summary-item', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('input'); + const focusStub = input ? stub(input, 'focus') : null; + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + if (focusStub) { + expect(focusStub).to.not.have.been.called; + focusStub.restore(); + } + }); + + it('does not focus input when clicking on INPUT element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [input], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); + + it('does not focus input when clicking on LABEL element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); }); diff --git a/src/elements/internal/InternalNumberControl/InternalNumberControl.ts b/src/elements/internal/InternalNumberControl/InternalNumberControl.ts index f767d641d..318611b19 100644 --- a/src/elements/internal/InternalNumberControl/InternalNumberControl.ts +++ b/src/elements/internal/InternalNumberControl/InternalNumberControl.ts @@ -100,6 +100,16 @@ export class InternalNumberControl extends InternalEditableControl { `; } + protected _handleHostClick(evt: MouseEvent): void { + if (this.layout !== 'summary-item') return; + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['INPUT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('input')?.focus(); + super._handleHostClick(evt); + } + } + private __renderSummaryItemLayout() { return html`
diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts index 28415fcca..6c3afe2a9 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts @@ -596,4 +596,72 @@ describe('InternalResourcePickerControl', () => { ); }); }); + + it('clicks button when clicking outside BUTTON in summary-item layout', async () => { + const control = await fixture(html` + + `); + + const button = control.renderRoot.querySelector('button')!; + const clickStub = stub(button, 'click'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(clickStub).to.have.been.calledOnce; + + clickStub.restore(); + }); + + it('does not click button when layout is not summary-item', async () => { + const control = await fixture(html` + + `); + + const button = control.renderRoot.querySelector('button'); + const clickStub = button ? stub(button, 'click') : null; + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + if (clickStub) { + expect(clickStub).to.not.have.been.called; + clickStub.restore(); + } + }); + + it('does not click button when clicking on BUTTON element', async () => { + const control = await fixture(html` + + `); + + const button = control.renderRoot.querySelector('button')!; + const clickStub = stub(button, 'click'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [button], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(clickStub).to.not.have.been.called; + + clickStub.restore(); + }); }); diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts index cca9bf28d..91af6b0ab 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts @@ -118,6 +118,16 @@ export class InternalResourcePickerControl extends InternalEditableControl { if (changes.has('item')) this.__getItemRenderer.cache.clear?.(); } + protected _handleHostClick(evt: MouseEvent): void { + if (this.layout !== 'summary-item') return; + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['BUTTON']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('button')?.click(); + super._handleHostClick(evt); + } + } + private __clear(): void { this._value = ''; this.dispatchEvent(new CustomEvent('clear')); diff --git a/src/elements/internal/InternalSelectControl/InternalSelectControl.test.ts b/src/elements/internal/InternalSelectControl/InternalSelectControl.test.ts index 45abde494..7af4f24cf 100644 --- a/src/elements/internal/InternalSelectControl/InternalSelectControl.test.ts +++ b/src/elements/internal/InternalSelectControl/InternalSelectControl.test.ts @@ -379,4 +379,92 @@ describe('InternalSelectControl', () => { expect(select).to.not.have.attribute('hidden'); }); + + it('focuses select when clicking outside SELECT and LABEL in summary-item layout', async () => { + const layout = html``; + const control = await fixture(layout); + + const select = control.renderRoot.querySelector('select')!; + const focusStub = stub(select, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.have.been.calledOnce; + + focusStub.restore(); + }); + + it('does not focus select when layout is not summary-item', async () => { + const layout = html``; + const control = await fixture(layout); + + const select = control.renderRoot.querySelector('select'); + const focusStub = select ? stub(select, 'focus') : null; + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + if (focusStub) { + expect(focusStub).to.not.have.been.called; + focusStub.restore(); + } + }); + + it('does not focus select when clicking on SELECT element', async () => { + const layout = html``; + const control = await fixture(layout); + + const select = control.renderRoot.querySelector('select')!; + const focusStub = stub(select, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [select], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); + + it('does not focus select when clicking on LABEL element', async () => { + const layout = html``; + const control = await fixture(layout); + + const select = control.renderRoot.querySelector('select')!; + const focusStub = stub(select, 'focus'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); }); diff --git a/src/elements/internal/InternalSelectControl/InternalSelectControl.ts b/src/elements/internal/InternalSelectControl/InternalSelectControl.ts index e70a1ff1e..f6c062720 100644 --- a/src/elements/internal/InternalSelectControl/InternalSelectControl.ts +++ b/src/elements/internal/InternalSelectControl/InternalSelectControl.ts @@ -154,4 +154,14 @@ export class InternalSelectControl extends InternalEditableControl {
`; } + + protected _handleHostClick(evt: MouseEvent): void { + if (this.layout !== 'summary-item') return; + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['SELECT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('select')?.focus(); + super._handleHostClick(evt); + } + } } diff --git a/src/elements/internal/InternalSwitchControl/InternalSwitchControl.test.ts b/src/elements/internal/InternalSwitchControl/InternalSwitchControl.test.ts index 19635876b..e86fb32b9 100644 --- a/src/elements/internal/InternalSwitchControl/InternalSwitchControl.test.ts +++ b/src/elements/internal/InternalSwitchControl/InternalSwitchControl.test.ts @@ -229,4 +229,68 @@ describe('InternalSwitchControl', () => { control._value = false; expect(control.setValue).to.have.been.calledOnceWith(false); }); + + it('clicks input when clicking outside INPUT and LABEL elements', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const clickStub = stub(input, 'click'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(clickStub).to.have.been.calledOnce; + + clickStub.restore(); + }); + + it('does not click input when clicking on INPUT element', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const clickStub = stub(input, 'click'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [input], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(clickStub).to.not.have.been.called; + + clickStub.restore(); + }); + + it('does not click input when clicking on LABEL element', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const clickStub = stub(input, 'click'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(clickStub).to.not.have.been.called; + + clickStub.restore(); + }); }); diff --git a/src/elements/internal/InternalSwitchControl/InternalSwitchControl.ts b/src/elements/internal/InternalSwitchControl/InternalSwitchControl.ts index 63470ceef..e887bce4a 100644 --- a/src/elements/internal/InternalSwitchControl/InternalSwitchControl.ts +++ b/src/elements/internal/InternalSwitchControl/InternalSwitchControl.ts @@ -119,4 +119,13 @@ export class InternalSwitchControl extends InternalEditableControl { else if (this.falseAlias && value === false) super._value = this.falseAlias; else super._value = value; } + + protected _handleHostClick(evt: MouseEvent): void { + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['INPUT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('input')?.click(); + super._handleHostClick(evt); + } + } } diff --git a/src/elements/internal/InternalTextControl/InternalTextControl.test.ts b/src/elements/internal/InternalTextControl/InternalTextControl.test.ts index da40cfbd3..4e412ea08 100644 --- a/src/elements/internal/InternalTextControl/InternalTextControl.test.ts +++ b/src/elements/internal/InternalTextControl/InternalTextControl.test.ts @@ -486,4 +486,98 @@ describe('InternalTextControl', () => { expect(control.renderRoot).to.include.text('Test Suffix'); }); + + it('focuses input when clicking outside INPUT and LABEL in summary-item layout', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-text-field')?.querySelector('input'); + if (!input) return; // Skip if input not found + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.have.been.calledOnce; + + focusStub.restore(); + }); + + it('does not focus input when layout is not summary-item', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('input'); + const focusStub = input ? stub(input, 'focus') : null; + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [control], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + if (focusStub) { + expect(focusStub).to.not.have.been.called; + focusStub.restore(); + } + }); + + it('does not focus input when clicking on INPUT element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-text-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [input], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); + + it('does not focus input when clicking on LABEL element', async () => { + const layout = html``; + const control = await fixture(layout); + + const input = control.renderRoot.querySelector('vaadin-text-field')?.querySelector('input'); + if (!input) return; + + const focusStub = stub(input, 'focus'); + const label = document.createElement('label'); + + const mockEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(mockEvent, 'composedPath', { + value: () => [label], + }); + + // @ts-expect-error accessing protected member for testing purposes + control._handleHostClick(mockEvent); + + expect(focusStub).to.not.have.been.called; + + focusStub.restore(); + }); }); diff --git a/src/elements/internal/InternalTextControl/InternalTextControl.ts b/src/elements/internal/InternalTextControl/InternalTextControl.ts index 6655b0e38..5a11b1d59 100644 --- a/src/elements/internal/InternalTextControl/InternalTextControl.ts +++ b/src/elements/internal/InternalTextControl/InternalTextControl.ts @@ -88,6 +88,16 @@ export class InternalTextControl extends InternalEditableControl { super._value = newValue as unknown | undefined; } + protected _handleHostClick(evt: MouseEvent): void { + if (this.layout !== 'summary-item') return; + const composedPath = evt.composedPath() as HTMLElement[]; + const noOp = new Set(['INPUT', 'LABEL']); + if (!composedPath.some(el => noOp.has(el.tagName))) { + this.renderRoot.querySelector('input')?.focus(); + super._handleHostClick(evt); + } + } + private __renderSummaryItemLayout() { return html`
diff --git a/src/elements/public/Donation/Donation.stories.mdx b/src/elements/public/Donation/Donation.stories.mdx index e9be5e345..8b6387be9 100644 --- a/src/elements/public/Donation/Donation.stories.mdx +++ b/src/elements/public/Donation/Donation.stories.mdx @@ -131,7 +131,7 @@ Quite often it's suitable to ask users for a donation amount outside of the list ## Adding designations -Sometimes you might want to ask your donors to pick a cause to contribute to, which can be particularly useful if your organization has multiple donation-sponsored programs. To display a designation picker, provide the list of choices in the `designations` attribute value in form of a JSON array like this: `designations=["Medical Care", "Daily Meals", "Housing"]`. You can also pre-select a specific designation by setting the `designation` attribute: `designation="Daily Meals"`. +Sometimes you might want to ask your donors to pick a cause to contribute to, which can be particularly useful if your organization has multiple donation-sponsored programs. To display a designation picker, provide the list of choices in the `designations` attribute value in form of a JSON array like this: `designations=["Medical Care", "Daily Meals", "Housing"]`. You can also pre-select a specific designation by setting the `designation` attribute: `designation='"Daily Meals"'`. Note that we are using single and double quotes here because this attribute is parsed as JSON. You can aggregate multiple designation under categories, for example: `designations='["Medical Care", ["Nutrition", ["Daily Meals", "Infant Nutrition Aid", "Elderly Nutrition Suplement"]], "Housing"]'`. @@ -141,7 +141,7 @@ If an aggregated designation is to be the default choice, set the `designation` mdxSource={dedent` { expect(new Form()).to.have.deep.property('options', []); }); + it('has a reactive property "operators"', () => { + expect(Form).to.have.deep.nested.property('properties.operators', { type: Array }); + expect(new Form()).to.have.deep.property('operators', Object.values(Operator)); + }); + it('renders query builder', async () => { const element = await fixture
(html` @@ -106,6 +111,7 @@ describe('FilterAttributeForm', () => { expect(control).to.be.instanceOf(customElements.get('foxy-query-builder')); expect(control).to.have.property('value', 'color=blue'); expect(control).to.have.property('disableZoom', true); + expect(control).to.have.deep.property('operators', Object.values(Operator)); const options: Form['options'] = [{ type: Type.String, label: 'option_color', path: 'color' }]; element.options = options; @@ -114,6 +120,14 @@ describe('FilterAttributeForm', () => { expect(control).to.have.deep.property('options', options); expect(control).to.have.property('docsHref', 'https://example.com'); + const operators: Form['operators'] = [ + { type: Operator.In, paths: ['name', 'description'] }, + Operator.Not, + ]; + element.operators = operators; + await element.requestUpdate(); + expect(control).to.have.deep.property('operators', operators); + element.edit({ value: '/foo?filter_query=color%3Dred' }); await element.requestUpdate(); expect(control).to.have.property('value', 'color=red'); diff --git a/src/elements/public/FilterAttributeForm/FilterAttributeForm.ts b/src/elements/public/FilterAttributeForm/FilterAttributeForm.ts index 5de230409..a5cb00ba6 100644 --- a/src/elements/public/FilterAttributeForm/FilterAttributeForm.ts +++ b/src/elements/public/FilterAttributeForm/FilterAttributeForm.ts @@ -1,13 +1,14 @@ +import type { ConditionalOperator, Option } from '../QueryBuilder/types'; import type { PropertyDeclarations } from 'lit-element'; import type { TemplateResult } from 'lit-html'; import type { QueryBuilder } from '../QueryBuilder/QueryBuilder'; -import type { Option } from '../QueryBuilder/types'; import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; import { encode, decode } from 'html-entities'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; +import { Operator } from '../QueryBuilder/types'; import { html } from 'lit-html'; const NS = 'filter-attribute-form'; @@ -34,6 +35,7 @@ export class FilterAttributeForm extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + operators: { type: Array }, defaults: {}, pathname: {}, docsHref: { attribute: 'docs-href' }, @@ -41,6 +43,9 @@ export class FilterAttributeForm extends Base { }; } + /** List of operators passed down to `QueryBuilder.operators`. */ + operators: (Operator | ConditionalOperator)[] = Object.values(Operator); + /** Default filter query. */ defaults: string | null = null; @@ -132,6 +137,7 @@ export class FilterAttributeForm extends Base { { '--lumo-border-radius-m': '4px', '--lumo-border-radius-s': '4px', '--lumo-font-family': - '-apple-system, "system-ui", Roboto, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + '-apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', '--lumo-font-size-m': '16px', '--lumo-font-size-s': '14px', '--lumo-font-size-xs': '13px', diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts index 2e2643bde..3137fc393 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts @@ -352,7 +352,8 @@ describe('PaymentsApiPaymentMethodForm', () => { payment-preset="https://foxy-payments-api.element/payment_presets/0" parent="https://foxy-payments-api.element/payment_presets/0/payment_methods" store="https://demo.api/hapi/stores/0" - .getImageSrc=${(type: string) => `https://example.com?type=${type}`} + .getImageSrc=${(type: string) => + `https://static.www.foxycart.com/email/v2/email_header_logo.png?type=${type}`} @fetch=${(evt: FetchEvent) => { if (evt.request.url.endsWith('/payment_presets/0/available_payment_methods')) { evt.preventDefault(); @@ -399,7 +400,7 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(await getByKey(group0Item0Button, 'conflict_message')).to.not.exist; expect(await getByTag(group0Item0Button, 'img')).to.have.attribute( 'src', - 'https://example.com?type=bar_one' + 'https://static.www.foxycart.com/email/v2/email_header_logo.png?type=bar_one' ); expect(group1Item0Button).to.exist; @@ -408,7 +409,7 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(group1Item0Button).to.not.include.text('conflict_message'); expect(await getByTag(group1Item0Button, 'img')).to.have.attribute( 'src', - 'https://example.com?type=foo_one' + 'https://static.www.foxycart.com/email/v2/email_header_logo.png?type=foo_one' ); expect(group1Item1Button).to.exist; @@ -416,7 +417,7 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(group1Item1Button).to.include.text('Foo Two'); expect(await getByTag(group1Item1Button, 'img')).to.have.attribute( 'src', - 'https://example.com?type=foo_two' + 'https://static.www.foxycart.com/email/v2/email_header_logo.png?type=foo_two' ); expect(group1Item1Button).to.include.text('conflict_message'); @@ -503,7 +504,8 @@ describe('PaymentsApiPaymentMethodForm', () => { payment-preset="https://foxy-payments-api.element/payment_presets/0" parent="https://foxy-payments-api.element/payment_presets/0/payment_methods" store="https://demo.api/hapi/stores/0" - .getImageSrc=${(type: string) => `https://example.com?type=${type}`} + .getImageSrc=${(type: string) => + `https://static.www.foxycart.com/email/v2/email_header_logo.png?type=${type}`} @fetch=${(evt: FetchEvent) => { if (evt.request.url.endsWith('/payment_presets/0/available_payment_methods')) { evt.preventDefault(); @@ -1981,7 +1983,8 @@ describe('PaymentsApiPaymentMethodForm', () => { payment-preset="https://foxy-payments-api.element/payment_presets/0" parent="https://foxy-payments-api.element/payment_presets/0/payment_methods" store="https://demo.api/hapi/stores/0" - .getImageSrc=${(type: string) => `https://example.com?type=${type}`} + .getImageSrc=${(type: string) => + `https://static.www.foxycart.com/email/v2/email_header_logo.png?type=${type}`} @fetch=${(evt: FetchEvent) => { if (evt.request.url.endsWith('/payment_presets/0/available_payment_methods')) { evt.preventDefault(); @@ -2027,7 +2030,7 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(await getByKey(group0Item0Button, 'conflict_message')).to.not.exist; expect(await getByTag(group0Item0Button, 'img')).to.have.attribute( 'src', - 'https://example.com?type=bar_one' + 'https://static.www.foxycart.com/email/v2/email_header_logo.png?type=bar_one' ); }); }); diff --git a/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.test.ts b/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.test.ts index df0ae9552..95feb8299 100644 --- a/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.test.ts +++ b/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.test.ts @@ -203,7 +203,8 @@ describe('PaymentsApiPaymentPresetForm', () => { const store = await getTestData>('./hapi/stores/0', router); store.is_active = true; (resolve as ((response: Response) => void) | null)?.(new Response(JSON.stringify(store))); - await new Promise(r => setTimeout(r)); + // @ts-expect-error using a private property for testing purposes + await waitUntil(() => element.__storeLoader?.data, '', { timeout: 5000 }); expect(element.hiddenSelector.matches('general:is-live', true)).to.be.false; expect(element.hiddenSelector.matches('general:is-purchase-order-enabled', true)).to.be.false; diff --git a/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.ts b/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.ts index 0d3872a6f..137947c61 100644 --- a/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.ts +++ b/src/elements/public/PaymentsApiPaymentPresetForm/PaymentsApiPaymentPresetForm.ts @@ -70,10 +70,10 @@ export class PaymentsApiPaymentPresetForm extends Base { get hiddenSelector(): BooleanSelector { const alwaysMatch = ['header:copy-json', super.hiddenSelector.toString()]; - const store = this.__storeLoader?.data; + const isStoreActive = !!this.__storeLoader?.data?.is_active; if (!this.data) alwaysMatch.unshift('payment-methods', 'fraud-protections'); - if (!store) alwaysMatch.unshift('general:is-live', 'general:is-purchase-order-enabled'); + if (!isStoreActive) alwaysMatch.unshift('general:is-live', 'general:is-purchase-order-enabled'); return new BooleanSelector(alwaysMatch.join(' ').trim()); } diff --git a/src/elements/public/QueryBuilder/QueryBuilder.stories.ts b/src/elements/public/QueryBuilder/QueryBuilder.stories.ts index 2d3c30b29..c13061996 100644 --- a/src/elements/public/QueryBuilder/QueryBuilder.stories.ts +++ b/src/elements/public/QueryBuilder/QueryBuilder.stories.ts @@ -2,7 +2,7 @@ import { TemplateResult, html } from 'lit-html'; import { QueryBuilder } from './index'; import { getMeta } from '../../../storygen/getMeta'; import { Summary } from '../../../storygen/Summary'; -import { Type } from './types'; +import { Operator, Type } from './types'; const demoLocalName = 'demo-query-builder'; if (!customElements.get(demoLocalName)) { @@ -16,6 +16,7 @@ const summary: Summary = { }; const options = JSON.stringify([ + { type: Type.NameValuePair, path: 'custom_fields', label: 'option_custom_fields' }, { type: Type.Attribute, path: 'attributes', label: 'option_attributes' }, { type: Type.Number, path: 'total_order', label: 'option_total_order' }, { label: 'option_data_is_fed', type: Type.Boolean, path: 'data_is_fed' }, @@ -32,10 +33,21 @@ const options = JSON.stringify([ }, ]); +const operators = JSON.stringify([ + { type: Operator.GreaterThan, paths: ['total_order', 'transaction_date'] }, + { type: Operator.GreaterThanOrEqual, paths: ['total_order', 'transaction_date'] }, + { type: Operator.LessThan, paths: ['total_order', 'transaction_date'] }, + { type: Operator.LessThanOrEqual, paths: ['total_order', 'transaction_date'] }, + { type: Operator.In, paths: ['status'] }, + { type: Operator.IsDefined, paths: ['attributes'] }, + Operator.Not, +]); + export default getMeta(summary); export const Playground = (): TemplateResult => html` html` export const Disabled = (): TemplateResult => html` html` export const Readonly = (): TemplateResult => html` html` `; export const Empty = (): TemplateResult => { - return html``; + return html` + + + `; }; diff --git a/src/elements/public/QueryBuilder/QueryBuilder.test.ts b/src/elements/public/QueryBuilder/QueryBuilder.test.ts index 7700af4f1..898c5f08b 100644 --- a/src/elements/public/QueryBuilder/QueryBuilder.test.ts +++ b/src/elements/public/QueryBuilder/QueryBuilder.test.ts @@ -107,6 +107,7 @@ describe('QueryBuilder', () => { const Type = QueryBuilder.Type; + expect(Type).to.have.property('NameValuePair', 'name_value_pair'); expect(Type).to.have.property('Attribute', 'attribute'); expect(Type).to.have.property('Boolean', 'boolean'); expect(Type).to.have.property('String', 'string'); @@ -261,6 +262,56 @@ describe('QueryBuilder', () => { } }); + it('restricts operators to specified paths using ConditionalOperator in advanced mode', async () => { + const layout = html` + + + `; + + const element = await fixture(layout); + const root = element.renderRoot; + const toggle = root.querySelector('button[title="operator_equal"]') as HTMLButtonElement; + + // For 'foo' path, should cycle through: equal, lessthan, greaterthan, not + const fooValues = ['foo%3Alessthan=', 'foo%3Agreaterthan=', 'foo%3Anot=', 'foo=']; + + for (const value of fooValues) { + const whenGotEvent = oneEvent(element, 'change'); + toggle.click(); + await element.requestUpdate(); + + expect(element).to.have.value(value); + expect(await whenGotEvent).to.be.instanceOf(QueryBuilder.ChangeEvent); + } + + // Switch to 'bar' path - should only have operators that include 'bar' in their paths + const pathInput = root.querySelector( + '[aria-label="query_builder_rule"] input' + ) as HTMLInputElement; + pathInput.value = 'bar'; + pathInput.dispatchEvent(new InputEvent('input')); + await element.requestUpdate(); + + // For 'bar' path, should cycle through: equal, greaterthan, not + const barValues = ['bar%3Agreaterthan=', 'bar%3Anot=', 'bar=']; + + for (const value of barValues) { + const whenGotEvent = oneEvent(element, 'change'); + toggle.click(); + await element.requestUpdate(); + + expect(element).to.have.value(value); + expect(await whenGotEvent).to.be.instanceOf(QueryBuilder.ChangeEvent); + } + }); + it('adds attribute-only operators to the cycle for attribute-like resources in advanced mode', async () => { const layout = html``; const element = await fixture(layout); @@ -492,6 +543,7 @@ describe('QueryBuilder', () => { expect(valueInput).to.have.value(''); valueInput.value = 'baz'; valueInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo=baz'); // Editing operator (Not) @@ -499,12 +551,88 @@ describe('QueryBuilder', () => { operatorSelect.dispatchEvent(new InputEvent('change')); expect(element).to.have.value('foo%3Anot=baz'); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle // Editing operator (Any) operatorSelect.value = 'any'; operatorSelect.dispatchEvent(new InputEvent('change')); expect(element).to.have.value(''); }); + it('renders a field for Type.NameValuePair option in simple mode', async () => { + const element = await fixture(html` + + + `); + + const rules = element.renderRoot.querySelectorAll('[aria-label="query_builder_rule"]'); + expect(rules).to.have.length(1); + + const rule = rules[0]; + const label = rule.querySelector('foxy-i18n[key="option_foo"][infer=""]'); + expect(label).to.exist; + + // Initial state - should have name input and operator select + const inputs = rule.querySelectorAll('input'); + expect(inputs).to.have.length(1); // Only name input initially + const nameInput = inputs[0]; + expect(nameInput).to.have.value(''); + + const selects = rule.querySelectorAll('select'); + expect(selects).to.have.length(1); // Operator select + const operatorSelect = selects[0]; + expect(operatorSelect.options).to.have.length(3); + expect(operatorSelect.options[0].value).to.equal('any'); + expect(operatorSelect.options[1].value).to.equal('equal'); + expect(operatorSelect.options[2].value).to.equal(Operator.Not); + + // Editing name + nameInput.value = 'bar'; + nameInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for debounce + expect(element).to.have.value('foo%3Aname%5Bbar%5D=*&zoom=foo'); + await element.requestUpdate(); + + // Editing operator (Equal) + operatorSelect.value = 'equal'; + operatorSelect.dispatchEvent(new InputEvent('change')); + expect(element).to.have.value('foo%3Aname%5Bbar%5D=&zoom=foo'); + await element.requestUpdate(); + + // Should now show value input + const newInputs = rule.querySelectorAll('input'); + expect(newInputs).to.have.length(2); // Name and value inputs + const valueInput = newInputs[1]; + expect(valueInput).to.exist; + expect(valueInput).to.have.value(''); + + // Editing value + valueInput.value = 'baz'; + valueInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for debounce + expect(element).to.have.value('foo%3Aname%5Bbar%5D=baz&zoom=foo'); + await element.requestUpdate(); + + // Editing operator (Not) + operatorSelect.value = Operator.Not; + operatorSelect.dispatchEvent(new InputEvent('change')); + expect(element).to.have.value('foo%3Aname%5Bbar%5D%3Anot=baz&zoom=foo'); + await element.requestUpdate(); + + // Editing operator (Any) + operatorSelect.value = 'any'; + operatorSelect.dispatchEvent(new InputEvent('change')); + expect(element).to.have.value('foo%3Aname%5Bbar%5D=*&zoom=foo'); + await element.requestUpdate(); + + // Editing name (empty) + nameInput.value = ''; + nameInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for debounce + expect(element).to.have.value(''); + }); + it('renders a field for Type.Number option in simple mode', async () => { const element = await fixture(html` { expect(valueInput).to.have.attribute('min', '10'); valueInput.value = '13'; valueInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo=13'); // Editing operator (Not) @@ -594,6 +723,7 @@ describe('QueryBuilder', () => { expect(rangeFromInput).to.have.value('13'); rangeFromInput.value = '15'; rangeFromInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo=15..23'); // Editing range value (To) @@ -603,6 +733,7 @@ describe('QueryBuilder', () => { expect(rangeFromInput).to.have.attribute('min', '10'); rangeToInput.value = '25'; rangeToInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo=15..25'); // Editing operator (Any) @@ -611,6 +742,54 @@ describe('QueryBuilder', () => { expect(element).to.have.value(''); }); + it('throttles input changes in simple mode text inputs', async () => { + const element = await fixture(html` + + + `); + + const rules = element.renderRoot.querySelectorAll('[aria-label="query_builder_rule"]'); + const rule = rules[0]; + + // Initial state - set to 'equal' operator to show value input + const operatorSelect = rule.querySelector('select')!; + operatorSelect.value = 'equal'; + operatorSelect.dispatchEvent(new InputEvent('change')); + await element.requestUpdate(); + + const valueInput = rule.querySelector('input') as HTMLInputElement; + + // Rapidly type characters - should not immediately update + valueInput.value = 'a'; + valueInput.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('foo='); // Not yet updated + + valueInput.value = 'ab'; + valueInput.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('foo='); // Still not updated + + valueInput.value = 'abc'; + valueInput.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('foo='); // Still not updated + + // Wait for the debounce timeout + await new Promise(resolve => setTimeout(resolve, 550)); + expect(element).to.have.value('foo=abc'); // Now updated + + // Test that subsequent rapid inputs also debounce correctly + valueInput.value = 'abcd'; + valueInput.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('foo=abc'); // Not yet updated + + valueInput.value = 'abcde'; + valueInput.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('foo=abc'); // Not yet updated + + // Wait for debounce + await new Promise(resolve => setTimeout(resolve, 550)); + expect(element).to.have.value('foo=abcde'); // Now updated + }); + it('renders a field for Type.Date option in simple mode', async () => { const element = await fixture(html` { expect(operatorSelect.options[0].innerText.trim()).to.equal('value_any'); expect(operatorSelect.options[1].value).to.equal('equal'); expect(operatorSelect.options[1].innerText.trim()).to.equal('operator_equal'); - expect(operatorSelect.options[2].value).to.equal('not'); - expect(operatorSelect.options[2].innerText.trim()).to.equal('operator_not'); - expect(operatorSelect.options[3].value).to.equal('lessthanorequal'); - expect(operatorSelect.options[3].innerText.trim()).to.equal('operator_lessthanorequal'); - expect(operatorSelect.options[4].value).to.equal('lessthan'); - expect(operatorSelect.options[4].innerText.trim()).to.equal('operator_lessthan'); - expect(operatorSelect.options[5].value).to.equal('greaterthanorequal'); - expect(operatorSelect.options[5].innerText.trim()).to.equal('operator_greaterthanorequal'); - expect(operatorSelect.options[6].value).to.equal('greaterthan'); - expect(operatorSelect.options[6].innerText.trim()).to.equal('operator_greaterthan'); - expect(operatorSelect.options[7].value).to.equal('range'); - expect(operatorSelect.options[7].innerText.trim()).to.equal('range'); // Editing operator (Equal) operatorSelect.value = 'equal'; @@ -660,6 +827,7 @@ describe('QueryBuilder', () => { expect(valueInput).to.have.attribute('min', '2020-10-05'); valueInput.value = '2024-12-06'; valueInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value(`foo=${inputToAPIDate('2024-12-06')}`); // Editing operator (Not) @@ -680,18 +848,6 @@ describe('QueryBuilder', () => { operatorSelect.dispatchEvent(new InputEvent('change')); expect(element).to.have.value(`foo%3Alessthan=${inputToAPIDate('2024-12-06')}`); - // Editing operator (GreaterThanOrEqual) - await element.requestUpdate(); - operatorSelect.value = 'greaterthanorequal'; - operatorSelect.dispatchEvent(new InputEvent('change')); - expect(element).to.have.value(`foo%3Agreaterthanorequal=${inputToAPIDate('2024-12-06')}`); - - // Editing operator (GreaterThan) - await element.requestUpdate(); - operatorSelect.value = 'greaterthan'; - operatorSelect.dispatchEvent(new InputEvent('change')); - expect(element).to.have.value(`foo%3Agreaterthan=${inputToAPIDate('2024-12-06')}`); - // Editing operator (Range) await element.requestUpdate(); operatorSelect.value = 'range'; @@ -708,6 +864,7 @@ describe('QueryBuilder', () => { expect(rangeFromInput).to.have.value('2024-11-06'); rangeFromInput.value = '2023-11-06'; rangeFromInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value( `foo=${inputToAPIDate('2023-11-06')}..${inputToAPIDate('2024-12-06', true)}` ); @@ -720,6 +877,7 @@ describe('QueryBuilder', () => { expect(rangeToInput).to.have.value('2024-12-06'); rangeToInput.value = '2025-01-02'; rangeToInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value( `foo=${inputToAPIDate('2023-11-06')}..${inputToAPIDate('2025-01-02', true)}` ); @@ -760,6 +918,7 @@ describe('QueryBuilder', () => { // Editing name nameInput.value = 'bar'; nameInput.dispatchEvent(new CustomEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo%3Aname%5Bbar%5D%3Aisdefined=true&zoom=foo'); await element.requestUpdate(); @@ -775,6 +934,7 @@ describe('QueryBuilder', () => { // Editing value valueInput.value = 'baz'; valueInput.dispatchEvent(new InputEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value('foo%3Aname%5Bbar%5D=baz&zoom=foo'); await element.requestUpdate(); @@ -793,6 +953,7 @@ describe('QueryBuilder', () => { // Editing name (empty) nameInput.value = ''; nameInput.dispatchEvent(new CustomEvent('input')); + await new Promise(resolve => setTimeout(resolve, 550)); // Wait for throttle expect(element).to.have.value(''); }); @@ -938,4 +1099,175 @@ describe('QueryBuilder', () => { expect(apiReferenceLink?.target).to.equal('_blank'); expect(apiReferenceLink?.rel).to.equal('nofollow noreferrer noopener'); }); + + it('prevents pipe character in path input and removes it in input handler', async () => { + const element = await fixture(html` + + `); + + // Advanced mode is the default when no options are provided + await element.requestUpdate(); + + const rule = element.renderRoot.querySelector('[aria-label="query_builder_rule"]'); + const pathInput = rule?.querySelector('input') as HTMLInputElement; + + // Simulate user typing a pipe character and having it filtered by input handler + pathInput.value = 'test|value'; + pathInput.dispatchEvent(new InputEvent('input')); + await element.requestUpdate(); + + // The pipe should be removed by @input handler + expect(pathInput.value).to.equal('testvalue'); + }); + + it('disables path input when element is disabled in advanced mode', async () => { + const options = [{ type: Type.String, path: 'product:name', label: 'product_name' }]; + + const element = await fixture(html` + + `); + + // Switch to advanced mode using UI toggle + const advancedModeTabLabel = element.renderRoot.querySelector( + 'foxy-i18n[key="mode_advanced"][infer=""]' + ); + const advancedModeTabInput = advancedModeTabLabel?.closest('label')?.querySelector('input'); + advancedModeTabInput!.checked = true; + advancedModeTabInput!.dispatchEvent(new CustomEvent('change')); + await element.requestUpdate(); + + let rule = element.renderRoot.querySelector('[aria-label="query_builder_rule"]'); + let inputs = rule?.querySelectorAll('input') as NodeListOf; + let anyDisabled = false; + inputs.forEach(input => { + if (input.hasAttribute('disabled')) anyDisabled = true; + }); + expect(anyDisabled).to.be.false; + + element.disabled = true; + await element.requestUpdate(); + + rule = element.renderRoot.querySelector('[aria-label="query_builder_rule"]'); + inputs = rule?.querySelectorAll('input') as NodeListOf; + anyDisabled = false; + inputs.forEach(input => { + if (input.hasAttribute('disabled')) anyDisabled = true; + }); + expect(anyDisabled).to.be.true; + }); + + it('disables path input when element is readonly in advanced mode', async () => { + const options = [{ type: Type.String, path: 'product:name', label: 'product_name' }]; + + const element = await fixture(html` + + `); + + // Switch to advanced mode using UI toggle + const advancedModeTabLabel = element.renderRoot.querySelector( + 'foxy-i18n[key="mode_advanced"][infer=""]' + ); + const advancedModeTabInput = advancedModeTabLabel?.closest('label')?.querySelector('input'); + advancedModeTabInput!.checked = true; + advancedModeTabInput!.dispatchEvent(new CustomEvent('change')); + await element.requestUpdate(); + + let rule = element.renderRoot.querySelector('[aria-label="query_builder_rule"]'); + let inputs = rule?.querySelectorAll('input') as NodeListOf; + let anyDisabled = false; + inputs.forEach(input => { + if (input.hasAttribute('disabled')) anyDisabled = true; + }); + expect(anyDisabled).to.be.false; + + element.readonly = true; + await element.requestUpdate(); + + rule = element.renderRoot.querySelector('[aria-label="query_builder_rule"]'); + inputs = rule?.querySelectorAll('input') as NodeListOf; + anyDisabled = false; + inputs.forEach(input => { + if (input.hasAttribute('disabled')) anyDisabled = true; + }); + expect(anyDisabled).to.be.true; + }); + + it('renders datalist with path options from .options in advanced mode', async () => { + const options = [ + { type: Type.String, path: 'product:name', label: 'product_name' }, + { type: Type.String, path: 'product:sku', label: 'product_sku' }, + { type: Type.Number, path: 'quantity', label: 'quantity' }, + ]; + + const element = await fixture(html` + + `); + + // Switch to advanced mode using UI toggle + const advancedModeTabLabel = element.renderRoot.querySelector( + 'foxy-i18n[key="mode_advanced"][infer=""]' + ); + const advancedModeTabInput = advancedModeTabLabel?.closest('label')?.querySelector('input'); + advancedModeTabInput!.checked = true; + advancedModeTabInput!.dispatchEvent(new CustomEvent('change')); + await element.requestUpdate(); + + // Find all datalists and check options + const datalists = element.renderRoot.querySelectorAll('datalist'); + let foundPathOptions = false; + + for (const dl of datalists) { + const opts = dl.querySelectorAll('option'); + if (opts.length === 3) { + const values = Array.from(opts).map(o => o.getAttribute('value')); + if ( + values.includes('product:name') && + values.includes('product:sku') && + values.includes('quantity') + ) { + foundPathOptions = true; + break; + } + } + } + + expect(foundPathOptions).to.be.true; + }); + + it('associates datalist with path input field via list attribute', async () => { + const options = [ + { type: Type.String, path: 'product:name', label: 'product_name' }, + { type: Type.String, path: 'quantity', label: 'quantity' }, + ]; + + const element = await fixture(html` + + `); + + // Switch to advanced mode using UI toggle + const advancedModeTabLabel = element.renderRoot.querySelector( + 'foxy-i18n[key="mode_advanced"][infer=""]' + ); + const advancedModeTabInput = advancedModeTabLabel?.closest('label')?.querySelector('input'); + advancedModeTabInput!.checked = true; + advancedModeTabInput!.dispatchEvent(new CustomEvent('change')); + await element.requestUpdate(); + + // Get all inputs with list attributes pointing to datalists + const inputs = element.renderRoot.querySelectorAll('input[list]'); + let found = false; + + for (const input of inputs) { + const listId = input.getAttribute('list'); + if (listId) { + const datalist = element.renderRoot.querySelector(`datalist#${listId}`); + if (datalist) { + found = true; + break; + } + } + } + + expect(found).to.be.true; + }); }); diff --git a/src/elements/public/QueryBuilder/QueryBuilder.ts b/src/elements/public/QueryBuilder/QueryBuilder.ts index 454e0da00..bd9e4d751 100644 --- a/src/elements/public/QueryBuilder/QueryBuilder.ts +++ b/src/elements/public/QueryBuilder/QueryBuilder.ts @@ -1,5 +1,5 @@ import type { CSSResultArray, PropertyDeclarations, TemplateResult } from 'lit-element'; -import type { Rule, Option } from './types'; +import type { Rule, Option, ConditionalOperator } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; import { ConfigurableMixin } from '../../../mixins/configurable'; @@ -74,8 +74,8 @@ export class QueryBuilder extends Base { /** If true, hides the UI for the "OR" operator in queries in the Advanced Mode. */ disableOr = false; - /** List of operators available in the builder UI. */ - operators: Operator[] = Object.values(Operator); + /** List of operators available in the builder UI. When an operator is an object, it specifies the operator type and the paths it applies to. */ + operators: (Operator | ConditionalOperator)[] = Object.values(Operator); /** When provided, will display a documentation link in the Advanced Mode. */ docsHref: string | null = null; @@ -102,9 +102,13 @@ export class QueryBuilder extends Base { const isSimpleModeSupported = this.__isSimpleModeSupported; const parsedValue = parse(this.__displayedValue ?? ''); const operators = this.operators ?? []; - const options = this.options ?? []; const t = this.t.bind(this); + const pathOptions = this.options?.reduce((acc, { type, path }) => { + if (type !== Type.Attribute && type !== Type.NameValuePair) return [...acc, path]; + return [...acc, `${path}[name]`, `${path}:name`, `${path}:value`]; + }, [] as string[]); + const onChange = (newParsedValue: (Rule | Rule[])[]) => { this.__displayedValue = stringify(newParsedValue, this.disableZoom); @@ -188,13 +192,14 @@ export class QueryBuilder extends Base {

${AdvancedGroup({ + pathOptions, disableOr: this.disableOr, disabled: this.disabled, readonly: this.readonly, rules: parsedValue, operators, onChange, - options, + id: 'advanced-group-root', t, })} ` diff --git a/src/elements/public/QueryBuilder/components/AdvancedGroup.ts b/src/elements/public/QueryBuilder/components/AdvancedGroup.ts index 8fb18b0c6..4b234363d 100644 --- a/src/elements/public/QueryBuilder/components/AdvancedGroup.ts +++ b/src/elements/public/QueryBuilder/components/AdvancedGroup.ts @@ -1,4 +1,4 @@ -import type { Operator, Option, Rule } from '../types'; +import type { ConditionalOperator, Operator, Rule } from '../types'; import type { TemplateResult } from 'lit-html'; import type { I18n } from '../../I18n/I18n'; @@ -7,13 +7,14 @@ import { repeat } from 'lit-html/directives/repeat'; import { html } from 'lit-html'; type Params = { - operators: Operator[]; + pathOptions?: string[]; + operators: (Operator | ConditionalOperator)[]; disableOr: boolean; isNested?: boolean; disabled: boolean; readonly: boolean; - options: Option[]; rules: (Rule | Rule[])[]; + id: string; t: I18n['t']; onChange: (newValue: (Rule | Rule[])[]) => void; }; @@ -46,7 +47,9 @@ export function AdvancedGroup(params: Params): TemplateResult { divider, AdvancedRule({ ...params, + id: `${params.id}-rule-${ruleIndex}`, rule: { path: '', operator: null, value: '' }, + operators: params.operators.filter(v => typeof v === 'string') as Operator[], isFullSize: !params.isNested && params.rules.length === 0, onChange: newValue => params.onChange([...params.rules, newValue]), }), @@ -62,6 +65,7 @@ export function AdvancedGroup(params: Params): TemplateResult { ...params, rules: rule, isNested: true, + id: `${params.id}-nested-${ruleIndex}`, onChange: newRule => { const newValue = [...params.rules]; const typedNewRule = newRule as Rule[]; @@ -79,6 +83,10 @@ export function AdvancedGroup(params: Params): TemplateResult { AdvancedRule({ ...params, rule: rule, + id: `${params.id}-rule-${ruleIndex}`, + operators: params.operators + .filter(v => !(typeof v === 'object') || v.paths.includes(rule.path)) + .map(v => (typeof v === 'object' ? v.type : v)), onChange: newValue => { const newRules = [...params.rules]; newRules[ruleIndex] = newValue; diff --git a/src/elements/public/QueryBuilder/components/AdvancedInput.ts b/src/elements/public/QueryBuilder/components/AdvancedInput.ts index c43f60412..dff3f9d6b 100644 --- a/src/elements/public/QueryBuilder/components/AdvancedInput.ts +++ b/src/elements/public/QueryBuilder/components/AdvancedInput.ts @@ -5,10 +5,12 @@ import { classMap } from '../../../../utils/class-map'; import { html } from 'lit-html'; type Params = { + pathOptions?: string[]; disabled: boolean; readonly: boolean; label: string; value: string; + id: string; t: I18n['t']; onChange: (newValue: string) => void; }; @@ -27,6 +29,7 @@ export function AdvancedInput(params: Params): TemplateResult { 'flex max-w-full whitespace-nowrap': true, // ugh safari 'focus-outline-none': true, })} + list="${params.id}-list" .value=${params.value} ?disabled=${params.disabled || params.readonly} @keydown=${(evt: KeyboardEvent) => evt.key === '|' && evt.preventDefault()} @@ -35,6 +38,10 @@ export function AdvancedInput(params: Params): TemplateResult { params.onChange(input.value.replace(/\|/gi, '')); }} /> + + + ${params.pathOptions?.map(value => html``)} +
1, - 'cursor-default text-tertiary': !isDisabled && availableOperators.length <= 1, + 'text-body hover-bg-contrast-5': !isDisabled && availableOperators.length > 0, + 'cursor-default text-tertiary': !isDisabled && availableOperators.length === 0, 'text-disabled cursor-default': isDisabled, })} - ?disabled=${isDisabled || availableOperators.length <= 1} + ?disabled=${isDisabled || availableOperators.length === 0} @click=${() => { const newOperatorIndex = operator ? availableOperators.indexOf(operator) : -1; const newOperator = availableOperators[newOperatorIndex + 1] ?? null; diff --git a/src/elements/public/QueryBuilder/components/AdvancedRule.ts b/src/elements/public/QueryBuilder/components/AdvancedRule.ts index 73f0285a0..10ed1c9ce 100644 --- a/src/elements/public/QueryBuilder/components/AdvancedRule.ts +++ b/src/elements/public/QueryBuilder/components/AdvancedRule.ts @@ -9,6 +9,7 @@ import { field } from '../icons/index'; import { html } from 'lit-html'; type Params = { + pathOptions?: string[]; isFullSize?: boolean; isNested?: boolean; operators: Operator[]; @@ -16,6 +17,7 @@ type Params = { readonly: boolean; disabled: boolean; rule: Rule; + id: string; t: I18n['t']; onConvert?: () => void; onDelete?: () => void; @@ -61,6 +63,7 @@ export function AdvancedRule(params: Params): TemplateResult { ...params, value: rule.path, label: 'field', + id: `${params.id}-path`, onChange: newPath => { onChange({ operator: null, value: '', path: newPath }); }, @@ -69,8 +72,10 @@ export function AdvancedRule(params: Params): TemplateResult {
${AdvancedInput({ ...params, + pathOptions: undefined, value: rule.name ?? '', label: 'name', + id: `${params.id}-name`, onChange: newValue => onChange({ ...rule, name: newValue }), })}
@@ -88,9 +93,11 @@ export function AdvancedRule(params: Params): TemplateResult {
${AdvancedInput({ ...params, + pathOptions: undefined, disabled: disabled || !rule.path, value: rule.value ?? '', label: 'value', + id: `${params.id}-value`, onChange: newValue => onChange({ ...rule, value: newValue }), })}
diff --git a/src/elements/public/QueryBuilder/components/SimpleGroup.ts b/src/elements/public/QueryBuilder/components/SimpleGroup.ts index 23e11be8a..7c22794db 100644 --- a/src/elements/public/QueryBuilder/components/SimpleGroup.ts +++ b/src/elements/public/QueryBuilder/components/SimpleGroup.ts @@ -1,6 +1,7 @@ import type { TemplateResult } from 'lit-html'; import type { Option, Rule } from '../types'; +import { SimpleNameValuePairRule } from './SimpleNameValuePairRule'; import { SimpleAttributeRule } from './SimpleAttributeRule'; import { SimpleBooleanRule } from './SimpleBooleanRule'; import { SimpleNumberRule } from './SimpleNumberRule'; @@ -47,6 +48,7 @@ export function SimpleGroup(params: Params): TemplateResult { const { type, label, list, path } = option; const controlsRenderers = { + [Type.NameValuePair]: SimpleNameValuePairRule, [Type.Attribute]: SimpleAttributeRule, [Type.Boolean]: SimpleBooleanRule, [Type.String]: SimpleStringRule, diff --git a/src/elements/public/QueryBuilder/components/SimpleInput.ts b/src/elements/public/QueryBuilder/components/SimpleInput.ts index 6902a41e4..fc57a72af 100644 --- a/src/elements/public/QueryBuilder/components/SimpleInput.ts +++ b/src/elements/public/QueryBuilder/components/SimpleInput.ts @@ -5,6 +5,8 @@ import { ifDefined } from 'lit-html/directives/if-defined'; import { classMap } from '../../../../utils/class-map'; import { html } from 'lit-html'; +const debounceTimers = new WeakMap(); + type Params = { disabled: boolean; readonly: boolean; @@ -57,7 +59,13 @@ export function SimpleInput(params: Params): TemplateResult { @keydown=${(evt: KeyboardEvent) => evt.key === '|' && evt.preventDefault()} @input=${(evt: Event) => { const input = evt.currentTarget as HTMLInputElement; - onChange(input.value.replace(/\|/gi, '')); + const newValue = input.value.replace(/\|/gi, ''); + + const existingTimer = debounceTimers.get(input); + if (existingTimer) clearTimeout(existingTimer); + + const timer = setTimeout(() => onChange(newValue), 500); + debounceTimers.set(input, timer); }} /> `} diff --git a/src/elements/public/QueryBuilder/components/SimpleNameValuePairRule.ts b/src/elements/public/QueryBuilder/components/SimpleNameValuePairRule.ts new file mode 100644 index 000000000..32fd06fc3 --- /dev/null +++ b/src/elements/public/QueryBuilder/components/SimpleNameValuePairRule.ts @@ -0,0 +1,79 @@ +import type { SimpleRuleComponent } from '../types'; + +import { SimpleSelect } from './SimpleSelect'; +import { SimpleInput } from './SimpleInput'; +import { Operator } from '../types'; +import { classMap } from '../../../../utils/class-map'; +import { html } from 'lit-html'; + +export const SimpleNameValuePairRule: SimpleRuleComponent = params => { + const { operator, value, name } = params.rule ?? {}; + const { disabled, readonly, t } = params; + + const not = Operator.Not; + const labelClass = classMap({ 'text-disabled': disabled, 'text-contrast-80': readonly }); + const displayedOperator = + operator === null ? (value === '*' ? null : 'equal') : operator === not ? not : null; + + return html` + + + ${SimpleInput({ + layout: 'auto-grow', + value: name || '', + label: 'name', + type: 'text', + disabled, + readonly, + t, + onChange: newValue => { + if (!newValue) return params.onChange(null); + if (value) { + params.onChange({ name: newValue }); + } else { + params.onChange({ name: newValue, operator: null, value: '*' }); + } + }, + })} + + + + ${SimpleSelect({ + current: { + label: displayedOperator ? `operator_${displayedOperator}` : 'value_any', + value: displayedOperator ?? 'any', + }, + options: [ + { label: 'value_any', value: 'any' }, + { label: 'operator_equal', value: 'equal' }, + { label: 'operator_not', value: not }, + ], + disabled, + readonly, + t, + onChange: newValue => { + if (newValue === 'any') { + params.onChange(name ? { operator: null, value: '*' } : null); + } else { + return params.onChange({ + operator: newValue === 'equal' ? null : (newValue as Operator), + value: newValue === 'equal' && value === '*' ? '' : value ?? '', + name: name ?? '', + }); + } + }, + })} + ${value === undefined || (operator === null && value === '*') + ? '' + : SimpleInput({ + layout: 'auto-grow', + label: 'value', + value: value || '', + type: 'text', + disabled, + readonly, + t, + onChange: newValue => params.onChange({ value: newValue }), + })} + `; +}; diff --git a/src/elements/public/QueryBuilder/types.ts b/src/elements/public/QueryBuilder/types.ts index f7be6527e..45284e975 100644 --- a/src/elements/public/QueryBuilder/types.ts +++ b/src/elements/public/QueryBuilder/types.ts @@ -2,6 +2,7 @@ import type { TemplateResult } from 'lit-html'; import type { I18n } from '../I18n/I18n'; export enum Type { + NameValuePair = 'name_value_pair', Attribute = 'attribute', Boolean = 'boolean', String = 'string', @@ -45,3 +46,5 @@ export type SimpleRuleComponent = (params: { t: I18n['t']; onChange: (newValue: Partial> | null) => void; }) => TemplateResult; + +export type ConditionalOperator = { type: Operator; paths: string[] }; diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts index 8629e8936..1179e9e3b 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts @@ -54,6 +54,13 @@ describe('UserInvitationForm', () => { }); }); + it('has a reactive property "currentStore"', () => { + expect(new Form()).to.have.property('currentStore', null); + expect(Form).to.have.deep.nested.property('properties.currentStore', { + attribute: 'current-store', + }); + }); + it('has a reactive property "currentUser"', () => { expect(new Form()).to.have.property('currentUser', null); expect(Form).to.have.deep.nested.property('properties.currentUser', { @@ -414,6 +421,7 @@ describe('UserInvitationForm', () => { const router = createRouter(); const form = await fixture(html` { expect(await selfrevokedEvent).to.exist; }); + it("renders a special async action for revoking current user's access in snapshot user layout", async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-post-action-control[infer="leave"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('href', form.data!._links['fx:revoke'].href); + expect(action).to.have.attribute('theme', 'error'); + expect(action).to.have.attribute( + 'message-options', + JSON.stringify({ store_domain: 'example', store_name: 'Example Store' }) + ); + + const selfrevokedEvent = oneEvent(form, 'selfrevoked'); + action!.dispatchEvent(new Event('success')); + expect(await selfrevokedEvent).to.exist; + }); + it('renders async action for resending invitation in snapshot admin layout', async () => { const router = createRouter(); const form = await fixture(html` diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.ts index e5c9bcaa5..773e49f6f 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.ts @@ -21,6 +21,7 @@ export class UserInvitationForm extends Base { ...super.properties, getStorePageHref: { attribute: false }, defaultDomain: { attribute: 'default-domain' }, + currentStore: { attribute: 'current-store' }, currentUser: { attribute: 'current-user' }, layout: {}, }; @@ -63,7 +64,10 @@ export class UserInvitationForm extends Base { /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ defaultDomain: string | null = null; - /** Currently logged in user resource URL. Used to display a warning when revoking access. */ + /** Currently viewed store URL. Used for determining when to emit `selfrevoked` event. */ + currentStore: string | null = null; + + /** Currently logged in user resource URL. Used for determining when to emit `selfrevoked` event. */ currentUser: string | null = null; /** Admin layout will display user info, user layout (default) will display store info. */ @@ -179,6 +183,14 @@ export class UserInvitationForm extends Base { const status = this.data?.status; const hidden = this.hiddenSelector; + const isOwnInvitationForCurrentStore = + this.currentStore && + this.currentUser && + _links?.['fx:store'] && + _links?.['fx:user'] && + this.currentStore === _links?.['fx:store'].href && + this.currentUser === _links?.['fx:user'].href; + return html`
@@ -241,7 +253,7 @@ export class UserInvitationForm extends Base { hidden.matches('resend', true) && hidden.matches('delete', true)} > - ${this.currentUser && _links['fx:user'] && this.currentUser === _links['fx:user'].href + ${isOwnInvitationForCurrentStore ? html` { 'text-error': status === 'rejected', }; + const isOwnInvitationForCurrentStore = + this.currentStore && + this.currentUser && + links?.['fx:store'] && + links?.['fx:user'] && + this.currentStore === links?.['fx:store'].href && + this.currentUser === links?.['fx:user'].href; + return html`
@@ -339,13 +359,30 @@ export class UserInvitationForm extends Base { > - - + ${isOwnInvitationForCurrentStore + ? html` + this.dispatchEvent(new CustomEvent('selfrevoked'))} + > + + ` + : html` + + + `}