diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3dfdd..59f2ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [0.0.265](https://github.com/getpingback/ui/compare/v0.0.264...v0.0.265) (2024-11-27) + + +### Bug Fixes + +* **variable-input:** initial content ([9a44ec0](https://github.com/getpingback/ui/commits/9a44ec0db6bc2e8e47056ec24e8939eb21c5b897)) + +### [0.0.263](https://github.com/getpingback/ui/compare/v0.0.262...v0.0.263) (2024-11-27) + + +### Bug Fixes + +* **variable-input:** reset search on close ([ff0075e](https://github.com/getpingback/ui/commits/ff0075e17bd53aac5ceca5a8f7ea73cb5f85684f)) + +### [0.0.261](https://github.com/getpingback/ui/compare/v0.0.260...v0.0.261) (2024-11-27) + + +### Features + +* **variable-input:** add custom fields and combobox ([3e89a07](https://github.com/getpingback/ui/commits/3e89a07602d029ad5a8ee5490631503a52706c9a)) + + +### Bug Fixes + +* change selection scope ([ec3c5b3](https://github.com/getpingback/ui/commits/ec3c5b31c39522303b26f2ca136d96c819806c8b)) +* **variable-input:** remove unused components ([844a5db](https://github.com/getpingback/ui/commits/844a5db2bd55b9fbdebd73dfcc14c1786cc3ae1a)) + ### [0.0.260](https://github.com/getpingback/ui/compare/v0.0.257...v0.0.260) (2024-11-27) diff --git a/package.json b/package.json index e855ddb..61ad529 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@getpingback/ui", "author": "Pingback Team", - "version": "0.0.260", + "version": "0.0.266", "license": "MIT", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/src/components/variable-input/variable-input.stories.tsx b/src/components/variable-input/variable-input.stories.tsx index deaa32b..9ad6421 100644 --- a/src/components/variable-input/variable-input.stories.tsx +++ b/src/components/variable-input/variable-input.stories.tsx @@ -1,44 +1,55 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from '@storybook/react'; -import { VariableInput } from "./variable-input"; +import { VariableInput } from './variable-input'; const meta = { - title: "Components/VariableInput", + title: 'Components/VariableInput', component: VariableInput, parameters: {}, - tags: ["autodocs"], + tags: ['autodocs'], - argTypes: {}, + argTypes: {} } satisfies Meta; export default meta; type Story = StoryObj; const options = [ - { label: "Email", value: "email" }, - { label: "Name", value: "name" }, - { label: "Phone", value: "phone" }, + { + heading: 'Common Variables', + items: [ + { label: 'Email', value: 'email' }, + { label: 'Name', value: 'name' }, + { label: 'Phone', value: 'phone' } + ] + }, + { + heading: 'Custom Variables', + items: [{ label: 'Custom Variable', value: 'customVariable' }] + } ]; export const Default: Story = { args: { - options, - }, + options + } }; export const WithInputProps: Story = { args: { - label: "URL", - placeholder: "https://www.example.com", - helperText: "Enter the URL of the page you want to redirect to", - options, - }, + label: 'URL', + placeholder: 'https://www.example.com', + helperText: 'Enter the URL of the page you want to redirect to', + options + } }; export const WithInitialContent: Story = { args: { - initialContent: "Hello {{name}}! How are you?", - options, - }, + label: 'Expression', + placeholder: 'Enter the expression', + initialContent: 'Hello {{name}}! How are you?', + options + } }; diff --git a/src/components/variable-input/variable-input.test.tsx b/src/components/variable-input/variable-input.test.tsx index cd52891..c62edf8 100644 --- a/src/components/variable-input/variable-input.test.tsx +++ b/src/components/variable-input/variable-input.test.tsx @@ -1,64 +1,114 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import { VariableInput } from "./variable-input"; +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { VariableInput } from './variable-input'; -describe("VariableInput Component", () => { +describe('VariableInput Component', () => { const options = [ - { label: "Variable 1", value: "var1" }, - { label: "Variable 2", value: "var2" }, + { + heading: 'Common Variables', + items: [ + { label: 'Email', value: 'email' }, + { label: 'Name', value: 'name' }, + { label: 'Phone', value: 'phone' } + ] + }, + { + heading: 'Custom Variables', + items: [{ label: 'Custom Variable', value: 'customVariable' }] + } ]; - it("should render with default props and open dropdown on trigger click", () => { + it('should render with default props and open dropdown on trigger click', () => { const { getByTestId, getByText } = render(); - const triggerButton = getByTestId("variable-input-trigger"); + const triggerButton = getByTestId('variable-input-trigger'); fireEvent.click(triggerButton); - expect(getByText("Variable 1")).toBeInTheDocument(); - expect(getByText("Variable 2")).toBeInTheDocument(); + expect(getByText('Email')).toBeInTheDocument(); + expect(getByText('Name')).toBeInTheDocument(); + expect(getByText('Phone')).toBeInTheDocument(); + expect(getByText('Custom Variable')).toBeInTheDocument(); }); - it("should call onChangeContent when text is input", () => { + it('should call onChangeContent when text is input', () => { const handleChangeContent = jest.fn(); const { container } = render(); const editor = container.querySelector("[contenteditable='true']"); if (editor) { - fireEvent.input(editor, { target: { textContent: "Hello" } }); - expect(handleChangeContent).toHaveBeenCalledWith("Hello"); + fireEvent.input(editor, { target: { textContent: 'Hello' } }); + expect(handleChangeContent).toHaveBeenCalledWith('Hello'); } }); - it("should call onSelect when a variable is selected", () => { + it('should call onSelect when a variable is selected', () => { const handleSelect = jest.fn(); - const { getByText, getByTestId } = render(); + const { getByText, getByTestId } = render(); - const triggerButton = getByTestId("variable-input-trigger"); + const triggerButton = getByTestId('variable-input-trigger'); fireEvent.click(triggerButton); - fireEvent.click(getByText("Variable 1")); - expect(handleSelect).toHaveBeenCalledWith("var1"); + fireEvent.click(getByText('Email')); + expect(handleSelect).toHaveBeenCalledWith('email'); }); - it("should display placeholder when not focused and empty", () => { - const { container } = render(); + it('should display placeholder when not focused and empty', () => { + const { container } = render(); const editor = container.querySelector("[contenteditable='true']"); if (editor) { - expect(editor.innerHTML).toContain("Enter text"); + expect(editor.innerHTML).toContain('Enter text'); } }); - it("should prevent editing of variable spans", () => { + it('should prevent editing of variable spans', () => { const { container } = render(); const editor = container.querySelector("[contenteditable='true']"); if (editor) { fireEvent.input(editor, { target: { innerHTML: 'Variable 1' } }); - fireEvent.keyDown(editor, { key: "Backspace" }); + fireEvent.keyDown(editor, { key: 'Backspace' }); + + expect(editor.innerHTML).toContain('Variable 1'); + } + }); + + it('should filter variables when text is typed in the search input', () => { + const { getByTestId, queryByText } = render(); + + const triggerButton = getByTestId('variable-input-trigger'); + fireEvent.click(triggerButton); + + const searchInput = getByTestId('variable-input-search-input'); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + expect(queryByText('Email')).toBeInTheDocument(); + expect(queryByText('Name')).not.toBeInTheDocument(); + expect(queryByText('Phone')).not.toBeInTheDocument(); + expect(queryByText('Custom Variable')).not.toBeInTheDocument(); + }); + + it('should show message when no variables are found in the search', () => { + const { getByTestId, getByText } = render(); + + const triggerButton = getByTestId('variable-input-trigger'); + fireEvent.click(triggerButton); + + const searchInput = getByTestId('variable-input-search-input'); + fireEvent.change(searchInput, { target: { value: 'xyz' } }); - expect(editor.innerHTML).toContain("Variable 1"); + expect(getByText('No results found')).toBeInTheDocument(); + }); + + it('should prioritize initialContent over placeholder', () => { + const initialContent = 'Initial content'; + const { container } = render(); + + const editor = container.querySelector("[contenteditable='true']"); + if (editor) { + expect(editor.innerHTML).toBe(initialContent); + expect(editor.innerHTML).not.toBe('Placeholder'); } }); }); diff --git a/src/components/variable-input/variable-input.tsx b/src/components/variable-input/variable-input.tsx index 1a60624..0c334de 100644 --- a/src/components/variable-input/variable-input.tsx +++ b/src/components/variable-input/variable-input.tsx @@ -1,55 +1,68 @@ -import * as React from "react"; -import { Button } from "../button"; -import { Dropdown, DropdownItem } from "../dropdown"; -import { VariableIcon } from "@stash-ui/editor-icons"; -import { CaretDownIcon } from "@stash-ui/light-icons"; -import { cn } from "@/lib/utils"; +import * as React from 'react'; +import { VariableIcon } from '@stash-ui/editor-icons'; +import { CaretDownIcon, CaretUpIcon } from '@stash-ui/light-icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '@/components/command'; + +interface Item { + value: string; + label: string; +} + +interface Option { + heading?: string; + items: Item[]; +} interface Props { + isLoadingVariables?: boolean; label?: string; helperText?: string; placeholder?: string; initialContent?: string; + options: Option[]; + variablesSearchValue?: string; + variablesShouldFilter?: boolean; + variablesSearchPlaceholder?: string; + variablesEmptySearchPlaceholder?: string; onChangeContent?: (content: string) => void; - onSelect?: (selectedVariable: string) => void; - options: { - label: string; - value: string; - }[]; + onSelectVariable?: (selectedVariable: string) => void; + onChangeVariablesSearchValue?: (variablesSearchValue: string) => void; + onVariablesEndReached?: () => void; } export type VariableInputProps = Props & React.HTMLAttributes; export function VariableInput({ + isLoadingVariables, label, placeholder, helperText, initialContent, - onChangeContent, - onSelect, options, className, + variablesSearchValue, + variablesShouldFilter = true, + variablesSearchPlaceholder = 'Search...', + variablesEmptySearchPlaceholder = 'No results found', + onChangeContent, + onSelectVariable, + onChangeVariablesSearchValue, + onVariablesEndReached, ...props }: VariableInputProps) { - const [text, setText] = React.useState(""); + const [open, setOpen] = React.useState(false); + const [text, setText] = React.useState(''); const [isFocused, setIsFocused] = React.useState(false); + const [lastRange, setLastRange] = React.useState(null); const editorRef = React.useRef(null); + const lastItemRef = React.useRef(null); - const placeholderTag = `${placeholder || ""}`; - - const transformContentToPlainText = (html: string) => { - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = html; - - const spans = tempDiv.querySelectorAll("span[data-variable]"); - spans.forEach((span) => { - const variable = span.getAttribute("data-variable"); - span.replaceWith(`{{${variable}}}`); - }); - - return tempDiv.textContent ?? ""; - }; + const placeholderTag = `${placeholder || ''}`; const handleInput = (e: React.FormEvent) => { const target = e.target as HTMLDivElement; @@ -67,17 +80,21 @@ export function VariableInput({ } }; + const handleSearchInput = (e: React.ChangeEvent) => { + onChangeVariablesSearchValue?.(e.target.value); + }; + const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); - document.execCommand("insertText", false, text); + const text = e.clipboardData.getData('text/plain'); + document.execCommand('insertText', false, text); }; const handleKeyDown = (e: React.KeyboardEvent) => { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); - const closestSpan = range.startContainer.parentElement?.closest("span[data-variable]"); + const closestSpan = range.startContainer.parentElement?.closest('span[data-variable]'); if (closestSpan && !e.metaKey && !e.ctrlKey) { e.preventDefault(); } @@ -87,58 +104,96 @@ export function VariableInput({ const handleVariableSelect = (selectedVariable: string) => { const editor = editorRef.current; - if (!isFocused) editor?.focus(); + if (!editor) return; - if (editor) { - if (editor.innerHTML === placeholderTag) editor.innerHTML = ""; + editor.focus(); - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const span = document.createElement("span"); - span.className = - "inline-flex items-center bg-transparent text-[#52525B] rounded-md border border-[#D4D4D8] px-1 py-0 text-sm font-medium mx-0.5"; - span.contentEditable = "false"; - span.dataset.variable = selectedVariable; - span.textContent = options.find((option) => option.value === selectedVariable)?.label || selectedVariable; - range.insertNode(span); - range.setStartAfter(span); - selection.removeAllRanges(); - selection.addRange(range); - } + if (editor.innerHTML === placeholderTag) editor.innerHTML = ''; + + const selection = window.getSelection(); + if (!lastRange || !editor.contains(lastRange.commonAncestorContainer)) { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } else { + selection?.removeAllRanges(); + selection?.addRange(lastRange); + } - onSelect?.(selectedVariable); - const plainText = transformContentToPlainText(editor.innerHTML); - onChangeContent?.(plainText); - setText(plainText); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const span = document.createElement('span'); + span.className = + 'inline-flex items-center bg-transparent text-[#52525B] rounded-md border border-[#D4D4D8] px-1 py-0 text-sm font-medium mx-0.5'; + span.contentEditable = 'false'; + span.dataset.variable = selectedVariable; + span.textContent = + options + .find((option) => option.items.find((item) => item.value === selectedVariable)) + ?.items.find((item) => item.value === selectedVariable)?.label || selectedVariable; + range.insertNode(span); + range.setStartAfter(span); + selection.removeAllRanges(); + selection.addRange(range); } + + onSelectVariable?.(selectedVariable); + const plainText = transformContentToPlainText(editor.innerHTML); + onChangeContent?.(plainText); + setText(plainText); }; const handleFocus = () => { setIsFocused(true); + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + setLastRange(selection.getRangeAt(0).cloneRange()); + } }; const handleBlur = () => { setIsFocused(false); }; + const handleDropdownTrigger = () => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + setLastRange(selection.getRangeAt(0).cloneRange()); + } + }; + + const transformContentToPlainText = (html: string) => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const spans = tempDiv.querySelectorAll('span[data-variable]'); + spans.forEach((span) => { + const variable = span.getAttribute('data-variable'); + span.replaceWith(`{{${variable}}}`); + }); + + return tempDiv.textContent ?? ''; + }; + const transformVariablesToSpans = (content: string) => { const regex = /{{([^}]+)}}/g; - const tempDiv = document.createElement("div"); + const tempDiv = document.createElement('div'); tempDiv.textContent = content; let match; while ((match = regex.exec(content)) !== null) { const variable = match[1]; - const option = options.find((opt) => opt.value === variable); + const option = options.find((opt) => opt.items.find((item) => item.value === variable)); if (option) { - const span = document.createElement("span"); + const span = document.createElement('span'); span.className = - "inline-flex items-center bg-transparent text-[#52525B] rounded-md border border-[#D4D4D8] px-1 py-0 text-sm font-medium mx-0.5"; - span.contentEditable = "false"; + 'inline-flex items-center bg-transparent text-[#52525B] rounded-md border border-[#D4D4D8] px-1 py-0 text-sm font-medium mx-0.5'; + span.contentEditable = 'false'; span.dataset.variable = variable; - span.textContent = option.label; + span.textContent = option.items.find((item) => item.value === variable)?.label || variable; tempDiv.innerHTML = tempDiv.innerHTML.replace(match[0], span.outerHTML); } @@ -147,71 +202,157 @@ export function VariableInput({ return tempDiv.innerHTML; }; + React.useEffect(() => { + const editor = editorRef.current; + + if (editor && initialContent) { + const contentWithSpans = transformVariablesToSpans(initialContent); + editor.innerHTML = contentWithSpans; + setText(initialContent); + } + }, [initialContent]); + React.useEffect(() => { const editor = editorRef.current; if (editor) { - if (!isFocused && !text && placeholder) { + if (!isFocused && !text && !initialContent && placeholder) { editor.innerHTML = placeholderTag; } else if (isFocused && editor.innerHTML === placeholderTag) { - editor.innerHTML = ""; + editor.innerHTML = ''; } } - }, [isFocused, text, placeholder]); + }, [isFocused, text, placeholder, initialContent]); React.useEffect(() => { - const editor = editorRef.current; - - if (editor && initialContent) { - const contentWithSpans = transformVariablesToSpans(initialContent); - editor.innerHTML = contentWithSpans; - setText(initialContent); + if (!open) { + onVariablesEndReached?.(); + return; } - }, []); + + let observer: IntersectionObserver; + setTimeout(() => { + observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onVariablesEndReached?.(); + } + }, + { threshold: 1 } + ); + + if (lastItemRef.current) { + observer?.observe(lastItemRef.current); + } + }, 0); + + return () => { + observer?.disconnect(); + }; + }, [lastItemRef.current, options, open]); return ( -
- {label ? ( - - ) : null} - -
-
- - - - + +
+ {label ? ( + + ) : null} + +
+
+ + + - } - > - {options.map((option) => ( - handleVariableSelect(option.value)}> - {option.label} - - ))} - + +
+ + {helperText ? {helperText} : null}
- {helperText ? ( - {helperText} - ) : null} -
+ + +
+ +
+ + {variablesEmptySearchPlaceholder} + +
+ {options.map((option, index) => ( + <> + + {option.items.map((item) => ( + { + handleVariableSelect(item.value); + setOpen(false); + }} + > +
+ {item.label} +
+
+ ))} +
+ + {index < options.length - 1 &&
} + + ))} + +
+
+ + {isLoadingVariables ? ( +
+ +
+ ) : null} + + + ); }