Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"suggestion",
"suggestionRow",
"expandButton",
"collapseButton",
"expandCollapseWrapper",
Expand Down Expand Up @@ -39,6 +40,7 @@ export default class extends Controller {
};

declare readonly suggestionTargets: HTMLButtonElement[];
declare readonly suggestionRowTargets: HTMLElement[];
declare readonly hasExpandButtonTarget: boolean;
declare readonly expandButtonTarget: HTMLButtonElement;
declare readonly hasCollapseButtonTarget: boolean;
Expand Down Expand Up @@ -76,6 +78,15 @@ export default class extends Controller {
/** Index of the suggestion pending deletion */
deleteIndex: number | null = null;

/** Whether the suggestions list is currently expanded */
expanded = false;

connect(): void {
this.expanded = false;
this.applySuggestionVisibility();
this.updateExpandCollapseState(this.getSuggestionRows().length);
}

// ─── Display: insert, hover, expand/collapse ────────────────

/**
Expand Down Expand Up @@ -114,41 +125,18 @@ export default class extends Controller {
* Show all hidden suggestions and toggle expand/collapse buttons.
*/
expand(): void {
// Show all hidden suggestion rows (the parent wrapper divs)
this.suggestionTargets.forEach((button) => {
const row = button.closest("[data-index]") as HTMLElement | null;
if (row) {
row.classList.remove("hidden");
}
});

if (this.hasExpandButtonTarget) {
this.expandButtonTarget.classList.add("hidden");
}
if (this.hasCollapseButtonTarget) {
this.collapseButtonTarget.classList.remove("hidden");
}
this.expanded = true;
this.applySuggestionVisibility();
this.updateExpandCollapseState(this.getSuggestionRows().length);
}

/**
* Hide suggestions beyond maxVisible and toggle expand/collapse buttons.
*/
collapse(): void {
this.suggestionTargets.forEach((button, index) => {
if (index >= this.maxVisibleValue) {
const row = button.closest("[data-index]") as HTMLElement | null;
if (row) {
row.classList.add("hidden");
}
}
});

if (this.hasExpandButtonTarget) {
this.expandButtonTarget.classList.remove("hidden");
}
if (this.hasCollapseButtonTarget) {
this.collapseButtonTarget.classList.add("hidden");
}
this.expanded = false;
this.applySuggestionVisibility();
this.updateExpandCollapseState(this.getSuggestionRows().length);
}

// ─── Add / Edit modal ───────────────────────────────────────
Expand Down Expand Up @@ -363,6 +351,7 @@ export default class extends Controller {
const row = document.createElement("div");
row.className = "group flex items-start gap-1";
row.dataset.index = String(index);
row.dataset.promptSuggestionsTarget = "suggestionRow";

const button = document.createElement("button");
button.type = "button";
Expand All @@ -381,10 +370,6 @@ export default class extends Controller {
actions.className =
"flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity";

if (index >= this.maxVisibleValue) {
row.classList.add("hidden");
}

// Edit button
const editBtn = document.createElement("button");
editBtn.type = "button";
Expand Down Expand Up @@ -414,7 +399,8 @@ export default class extends Controller {
container.appendChild(row);
});

// Update expand/collapse buttons based on the new suggestion count
this.expanded = false;
this.applySuggestionVisibility();
this.updateExpandCollapseState(suggestions.length);
}

Expand All @@ -432,23 +418,44 @@ export default class extends Controller {
if (hiddenCount > 0) {
this.expandCollapseWrapperTarget.classList.remove("hidden");

// Reset to collapsed state
if (this.hasExpandButtonTarget) {
this.expandButtonTarget.classList.remove("hidden");
this.expandButtonTarget.textContent = this.showMoreTemplateValue.replace(
"{count}",
String(hiddenCount),
);
this.expandButtonTarget.classList.toggle("hidden", this.expanded);
this.expandButtonTarget.setAttribute("aria-expanded", String(this.expanded));
}
if (this.hasCollapseButtonTarget) {
this.collapseButtonTarget.classList.add("hidden");
this.collapseButtonTarget.textContent = this.showLessLabelValue;
this.collapseButtonTarget.classList.toggle("hidden", !this.expanded);
this.collapseButtonTarget.setAttribute("aria-expanded", String(this.expanded));
}
} else {
this.expanded = false;
this.expandCollapseWrapperTarget.classList.add("hidden");
}
}

private getSuggestionRows(): HTMLElement[] {
if (!this.hasSuggestionListTarget) {
return this.suggestionRowTargets;
}

return Array.from(
this.suggestionListTarget.querySelectorAll<HTMLElement>(
'[data-prompt-suggestions-target~="suggestionRow"]',
),
);
}

private applySuggestionVisibility(): void {
this.getSuggestionRows().forEach((row, index) => {
const shouldHide = !this.expanded && index >= this.maxVisibleValue;
row.classList.toggle("hidden", shouldHide);
});
}

private sanitizeText(raw: string): string {
return (
raw
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@

{# Suggestions list #}
<div {{ stimulus_target('prompt-suggestions', 'suggestionList') }}
id="prompt-suggestions-list-{{ conversation.id }}"
class="flex flex-col gap-2">
{% for suggestion in promptSuggestions %}
<div class="group flex items-start gap-1 {{ loop.index > 3 ? 'hidden' : '' }}" data-index="{{ loop.index0 }}">
<div {{ stimulus_target('prompt-suggestions', 'suggestionRow') }}
class="group flex items-start gap-1 {{ loop.index > 3 ? 'hidden' : '' }}"
data-index="{{ loop.index0 }}">
<button type="button"
{{ stimulus_target('prompt-suggestions', 'suggestion') }}
data-action="click->prompt-suggestions#insert mouseenter->prompt-suggestions#hoverStart mouseleave->prompt-suggestions#hoverEnd"
Expand Down Expand Up @@ -72,12 +75,16 @@
<button type="button"
{{ stimulus_target('prompt-suggestions', 'expandButton') }}
{{ stimulus_action('prompt-suggestions', 'expand', 'click') }}
aria-controls="prompt-suggestions-list-{{ conversation.id }}"
aria-expanded="false"
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">
{{ 'editor.show_more_suggestions'|trans({'%count%': promptSuggestions|length - 3}) }}
</button>
<button type="button"
{{ stimulus_target('prompt-suggestions', 'collapseButton') }}
{{ stimulus_action('prompt-suggestions', 'collapse', 'click') }}
aria-controls="prompt-suggestions-list-{{ conversation.id }}"
aria-expanded="false"
class="hidden text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">
{{ 'editor.hide_suggestions'|trans }}
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Application } from "@hotwired/stimulus";
import PromptSuggestionsController from "../../../../src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts";

describe("PromptSuggestionsController", () => {
interface ControllerFixture {
controller: PromptSuggestionsController;
suggestionButtons: HTMLButtonElement[];
suggestionRows: HTMLElement[];
expandButton: HTMLButtonElement;
collapseButton: HTMLButtonElement;
expandCollapseWrapper: HTMLElement;
Expand All @@ -22,9 +24,11 @@ describe("PromptSuggestionsController", () => {
// Create suggestion buttons wrapped in row divs
const suggestionList = document.createElement("div");
const suggestionButtons: HTMLButtonElement[] = [];
const suggestionRows: HTMLElement[] = [];
for (let i = 0; i < 5; i++) {
const row = document.createElement("div");
row.dataset.index = String(i);
row.dataset.promptSuggestionsTarget = "suggestionRow";
row.className = "group flex items-start gap-1";

if (i >= 3) {
Expand All @@ -41,6 +45,7 @@ describe("PromptSuggestionsController", () => {
row.appendChild(btn);
row.appendChild(actions);
suggestionList.appendChild(row);
suggestionRows.push(row);
}

// Create expand/collapse buttons wrapped in a container
Expand All @@ -64,6 +69,7 @@ describe("PromptSuggestionsController", () => {
// Set up controller state
const state = controller as unknown as {
suggestionTargets: HTMLButtonElement[];
suggestionRowTargets: HTMLElement[];
hasExpandButtonTarget: boolean;
expandButtonTarget: HTMLButtonElement;
hasCollapseButtonTarget: boolean;
Expand Down Expand Up @@ -96,6 +102,7 @@ describe("PromptSuggestionsController", () => {
};

state.suggestionTargets = suggestionButtons;
state.suggestionRowTargets = suggestionRows;
state.hasExpandButtonTarget = true;
state.expandButtonTarget = expandButton;
state.hasCollapseButtonTarget = true;
Expand Down Expand Up @@ -129,6 +136,7 @@ describe("PromptSuggestionsController", () => {
return {
controller,
suggestionButtons,
suggestionRows,
expandButton,
collapseButton,
expandCollapseWrapper,
Expand Down Expand Up @@ -780,3 +788,154 @@ describe("PromptSuggestionsController", () => {
});
});
});

describe("PromptSuggestionsController click integration", () => {
let application: Application;

const flushMicrotasks = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};

const buildControllerHtml = (count: number): string => {
const rows = Array.from({ length: count })
.map((_, index) => {
const hiddenClass = index >= 3 ? "hidden" : "";
return `
<div data-prompt-suggestions-target="suggestionRow"
data-index="${index}"
class="group flex items-start gap-1 ${hiddenClass}">
<button type="button"
data-prompt-suggestions-target="suggestion"
data-action="click->prompt-suggestions#insert"
data-text="Suggestion ${index + 1}">
Suggestion ${index + 1}
</button>
</div>
`;
})
.join("");

const hiddenCount = Math.max(count - 3, 0);

return `
<div data-controller="prompt-suggestions"
data-prompt-suggestions-max-visible-value="3"
data-prompt-suggestions-show-more-template-value="+{count} more"
data-prompt-suggestions-show-less-label-value="Show less">
<div data-prompt-suggestions-target="suggestionList" id="prompt-suggestions-list-test">
${rows}
</div>
<div data-prompt-suggestions-target="expandCollapseWrapper" class="${count > 3 ? "" : "hidden"}">
<button type="button"
data-prompt-suggestions-target="expandButton"
data-action="click->prompt-suggestions#expand"
aria-controls="prompt-suggestions-list-test"
aria-expanded="false">
+${hiddenCount} more
</button>
<button type="button"
data-prompt-suggestions-target="collapseButton"
data-action="click->prompt-suggestions#collapse"
class="hidden"
aria-controls="prompt-suggestions-list-test"
aria-expanded="false">
Show less
</button>
</div>
</div>
`;
};

beforeEach(() => {
document.body.innerHTML = "";
application = Application.start();
application.register("prompt-suggestions", PromptSuggestionsController);
});

afterEach(() => {
application.stop();
vi.restoreAllMocks();
});

it("clicking +X more reveals hidden suggestions and toggles buttons", async () => {
document.body.innerHTML = buildControllerHtml(5);
await flushMicrotasks();

const expandButton = document.querySelector(
'[data-prompt-suggestions-target="expandButton"]',
) as HTMLButtonElement;
const collapseButton = document.querySelector(
'[data-prompt-suggestions-target="collapseButton"]',
) as HTMLButtonElement;
const rows = Array.from(
document.querySelectorAll('[data-prompt-suggestions-target="suggestionRow"]'),
) as HTMLElement[];

expect(rows[3].classList.contains("hidden")).toBe(true);
expect(rows[4].classList.contains("hidden")).toBe(true);

expandButton.click();
await flushMicrotasks();

rows.forEach((row) => {
expect(row.classList.contains("hidden")).toBe(false);
});
expect(expandButton.classList.contains("hidden")).toBe(true);
expect(collapseButton.classList.contains("hidden")).toBe(false);
expect(expandButton.getAttribute("aria-expanded")).toBe("true");
expect(collapseButton.getAttribute("aria-expanded")).toBe("true");
});

it("clicking Show less restores collapsed state after expansion", async () => {
document.body.innerHTML = buildControllerHtml(5);
await flushMicrotasks();

const expandButton = document.querySelector(
'[data-prompt-suggestions-target="expandButton"]',
) as HTMLButtonElement;
const collapseButton = document.querySelector(
'[data-prompt-suggestions-target="collapseButton"]',
) as HTMLButtonElement;
const rows = Array.from(
document.querySelectorAll('[data-prompt-suggestions-target="suggestionRow"]'),
) as HTMLElement[];

expandButton.click();
await flushMicrotasks();
collapseButton.click();
await flushMicrotasks();

expect(rows[0].classList.contains("hidden")).toBe(false);
expect(rows[1].classList.contains("hidden")).toBe(false);
expect(rows[2].classList.contains("hidden")).toBe(false);
expect(rows[3].classList.contains("hidden")).toBe(true);
expect(rows[4].classList.contains("hidden")).toBe(true);
expect(expandButton.classList.contains("hidden")).toBe(false);
expect(collapseButton.classList.contains("hidden")).toBe(true);
expect(expandButton.getAttribute("aria-expanded")).toBe("false");
expect(collapseButton.getAttribute("aria-expanded")).toBe("false");
});

it("renders the correct +X more label for larger suggestion lists", async () => {
document.body.innerHTML = buildControllerHtml(8);
await flushMicrotasks();

const expandButton = document.querySelector(
'[data-prompt-suggestions-target="expandButton"]',
) as HTMLButtonElement;
const rows = Array.from(
document.querySelectorAll('[data-prompt-suggestions-target="suggestionRow"]'),
) as HTMLElement[];

expect(expandButton.textContent?.trim()).toBe("+5 more");
expect(rows[7].classList.contains("hidden")).toBe(true);

expandButton.click();
await flushMicrotasks();

rows.forEach((row) => {
expect(row.classList.contains("hidden")).toBe(false);
});
});
});