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
431 changes: 208 additions & 223 deletions apps/frontend/package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,230 @@
<div class="relative">
<!-- Click-outside backdrop -->
@if (aiDropdownOpen()) {
<div class="fixed inset-0 z-40" (click)="aiDropdownOpen.set(false)"></div>
}
<!-- Dropdown panel (opens upward) -->
@if (aiDropdownOpen()) {
<div
class="absolute bottom-full right-0 mb-2 w-72 bg-white rounded-lg shadow-xl border border-gray-300 z-50 overflow-hidden"
>
<!-- Sliding inner container: two panels side by side -->
<div
class="flex transition-transform duration-200 ease-in-out"
[style.transform]="
aiDropdownView() === 'prompt' ? 'translateX(-288px)' : 'translateX(0)'
"
[style.width]="'576px'"
>
<!-- ── Panel 1: main menu ── -->
<div class="w-72 flex-shrink-0">
<div class="px-1 pt-1 pb-0.5">
<!-- Rewrite & improve -->
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border border-transparent hover:bg-secondary-active transition-colors text-left disabled:opacity-50"
[disabled]="isGeneratingAI() || aiSelectedFieldsCount() === 0"
(click)="onGenerateWithAI('rewrite')"
>
<span
class="flex-shrink-0 w-8 h-8 rounded-lg bg-teal-600/10 flex items-center justify-center text-teal-600"
>
<ng-icon name="iconoirMagicWand" size="1.1em" />
</span>
<div>
<div class="text-sm text-secondary-darker">
{{ 'footer.ai.rewriteAndImprove' | translate }}
</div>
<div class="text-xs text-gray-700">
{{ 'footer.ai.rewriteAndImprove.hint' | translate }}
</div>
</div>
</button>
<!-- Regenerate -->
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 mx-0 mt-0.5 rounded-lg border border-transparent hover:bg-secondary-active transition-colors text-left disabled:opacity-50"
[disabled]="isGeneratingAI() || aiSelectedFieldsCount() === 0"
(click)="onGenerateWithAI('regenerate')"
>
<span
class="flex-shrink-0 w-8 h-8 rounded-lg bg-teal-600/10 flex items-center justify-center text-teal-600"
>
<ng-icon name="iconoirSparks" size="1.1em" />
</span>
<div>
<div class="text-sm text-secondary-darker">
{{ 'footer.ai.regenerate' | translate }}
</div>
<div class="text-xs text-gray-700">
{{ 'footer.ai.regenerate.hint' | translate }}
</div>
</div>
</button>
<!-- Add your prompt -->
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 mx-0 mt-0.5 mb-0.5 rounded-lg border border-transparent hover:bg-secondary-active transition-colors text-left disabled:opacity-50"
[disabled]="isGeneratingAI() || aiSelectedFieldsCount() === 0"
(click)="aiDropdownView.set('prompt')"
>
<span
class="flex-shrink-0 w-8 h-8 rounded-lg bg-teal-600/10 flex items-center justify-center text-teal-600"
>
<ng-icon name="iconoirEditPencil" size="1.1em" />
</span>
<div>
<div class="text-sm text-secondary-darker">
{{ 'footer.ai.addYourPrompt' | translate }}
</div>
<div class="text-xs text-gray-700">
{{ 'footer.ai.addYourPrompt.hint' | translate }}
</div>
</div>
</button>
</div>
<!-- Apply to fields -->
<div class="border-t border-gray-200 px-4 pt-3 pb-3 bg-[#faf9f6]">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-700 tracking-wider">
{{ 'footer.ai.applyToFields' | translate }}
</span>
<button
type="button"
class="text-xs text-teal-600 hover:text-teal-700 transition-colors"
(click)="toggleAllAiFields()"
>
{{
(aiSelectedFieldsCount() > 0
? 'footer.ai.deselectAll'
: 'footer.ai.selectAll'
) | translate
}}
</button>
</div>
<div class="grid grid-cols-2 gap-y-2 gap-x-3">
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().title"
(change)="toggleAiField('title')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.title' | translate }}
</label>
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().abstract"
(change)="toggleAiField('abstract')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.abstract' | translate }}
</label>
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().keywords"
(change)="toggleAiField('keywords')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.keywords' | translate }}
</label>
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().topics"
(change)="toggleAiField('topics')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.topics' | translate }}
</label>
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().dateExtents"
(change)="toggleAiField('dateExtents')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.dateExtents' | translate }}
</label>
<label
class="flex items-center gap-2 cursor-pointer text-sm text-gray-800 select-none"
>
<input
type="checkbox"
[checked]="aiFields().attributeTables"
(change)="toggleAiField('attributeTables')"
class="accent-teal-500 w-4 h-4 rounded cursor-pointer"
/>
{{ 'footer.ai.field.attributeTables' | translate }}
</label>
</div>
<div class="mt-3 -mx-4 border-t border-gray-200"></div>
<div class="mt-3 text-xs text-gray-500 flex items-center gap-1.5">
<ng-icon name="iconoirSparks" size="0.9em" class="text-teal-500" />
{{ aiSelectedFieldsCount() }}
{{ 'footer.ai.fieldsSelected' | translate }}
</div>
</div>
</div>

<!-- ── Panel 2: custom prompt ── -->
<div class="w-72 flex-shrink-0 flex flex-col">
<!-- Header with back arrow -->
<div class="flex items-center gap-2 px-3 pt-3 pb-2.5">
<span class="text-sm font-medium text-secondary-darker">
{{ 'footer.ai.customPrompt.title' | translate }}
</span>
</div>
<!-- Textarea + send -->
<div class="h-full px-3 pb-3 flex flex-col">
<textarea
class="w-full h-full text-sm text-gray-800 bg-white border border-gray-300 rounded px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-teal-400 focus:border-transparent placeholder:text-gray-400"
[placeholder]="'footer.ai.promptPlaceholder' | translate"
[value]="aiCustomPrompt()"
(input)="aiCustomPrompt.set($any($event.target).value)"
></textarea>
<button
type="button"
class="mt-2 w-full flex items-center justify-center gap-2 px-3 py-2 rounded bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
[disabled]="isGeneratingAI() || !aiCustomPrompt().trim()"
(click)="onSendCustomPrompt()"
>
<ng-icon name="iconoirSend" size="1em" />
{{ 'footer.ai.send' | translate }}
</button>
</div>
<div class="border-t border-gray-200 px-4 pt-3 pb-3 bg-[#faf9f6]">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-700 tracking-wider">
{{ 'footer.ai.appliesToFields' | translate }}
</span>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
@for (fieldLabelKey of aiSelectedFieldLabelKeys(); track
fieldLabelKey) {
<span class="text-sm text-gray-800">
{{ fieldLabelKey | translate }}
</span>
}
</div>
</div>
</div>
</div>
</div>
}
<!-- Split button -->
<div
class="group/btn min-w-[200px] py-0.5 flex items-center justify-center rounded overflow-hidden border border-transparent hover:border hover:bg-secondary-active transition-colors"
>
Expand All @@ -17,7 +243,22 @@
[class.animate-spin]="isGeneratingAI()"
/>
</span>
{{ 'footer.ai.regenerate' | translate }}
{{ mainAiLabelKey() | translate }}
</button>

<button
type="button"
class="flex items-center justify-center px-2 self-stretch bg-transparent text-secondary hover:bg-secondary-dark/20 rounded-r transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
[disabled]="isGeneratingAI()"
(click)="toggleAiDropdown()"
[attr.aria-expanded]="aiDropdownOpen()"
>
<ng-icon
name="iconoirNavArrowDown"
size="1em"
class="flex-shrink-0 transition-transform"
[class.rotate-180]="aiDropdownOpen()"
/>
</button>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,80 @@ describe('AiGenerateButtonComponent', () => {
})

describe('initial state', () => {
it('should default to rewrite mode', () => {
expect(component.lastAiMode()).toBe('rewrite')
it('should default to regenerate mode', () => {
expect(component.lastAiMode()).toBe('regenerate')
})

it('should have all fields checked by default', () => {
const fields = component.aiFields()
expect(Object.values(fields).every(Boolean)).toBe(true)
})

it('should count all 6 fields selected', () => {
expect(component.aiSelectedFieldsCount()).toBe(6)
})

it('should start with dropdown closed', () => {
expect(component.aiDropdownOpen()).toBe(false)
})

it('should not be generating', () => {
expect(component.isGeneratingAI()).toBe(false)
})
})

describe('toggleAiDropdown()', () => {
it('should open the dropdown', () => {
component.toggleAiDropdown()
expect(component.aiDropdownOpen()).toBe(true)
})

it('should close the dropdown when called again', () => {
component.toggleAiDropdown()
component.toggleAiDropdown()
expect(component.aiDropdownOpen()).toBe(false)
})
})

describe('toggleAiField()', () => {
it('should uncheck a field', () => {
component.toggleAiField('title')
expect(component.aiFields().title).toBe(false)
expect(component.aiSelectedFieldsCount()).toBe(5)
})

it('should recheck an unchecked field', () => {
component.toggleAiField('title')
component.toggleAiField('title')
expect(component.aiFields().title).toBe(true)
})
})

describe('toggleAllAiFields()', () => {
it('should deselect all when all are selected', () => {
component.toggleAllAiFields()
expect(component.aiSelectedFieldsCount()).toBe(0)
expect(Object.values(component.aiFields()).every((v) => !v)).toBe(true)
})

it('should select all when none are selected', () => {
component.toggleAllAiFields() // deselect all
component.toggleAllAiFields() // reselect all
expect(component.aiSelectedFieldsCount()).toBe(6)
})
})

describe('mainAiLabelKey', () => {
it('should return regenerate label by default', () => {
expect(component.mainAiLabelKey()).toBe('footer.ai.regenerate')
})

it('should return rewrite label after rewrite mode is set', () => {
component.lastAiMode.set('rewrite')
expect(component.mainAiLabelKey()).toBe('footer.ai.rewriteAndImprove')
})
})

describe('onGenerateWithAI()', () => {
const mockRecord = {
title: 'My Title',
Expand All @@ -110,9 +175,11 @@ describe('AiGenerateButtonComponent', () => {
recordSubject.next(mockRecord)
})

it('should set lastAiMode', async () => {
it('should set lastAiMode and close dropdown', async () => {
component.aiDropdownOpen.set(true)
await component.onGenerateWithAI('rewrite')
expect(component.lastAiMode()).toBe('rewrite')
expect(component.aiDropdownOpen()).toBe(false)
})

it('should call the API with mode and current values', async () => {
Expand All @@ -128,21 +195,26 @@ describe('AiGenerateButtonComponent', () => {
})
})

it('should update all fields in the editor', async () => {
it('should update only checked fields in the editor', async () => {
component.aiFields.update((f) => ({
...f,
abstract: false,
topics: false
}))
await component.onGenerateWithAI('regenerate')
expect(mockEditorFacade.updateRecordField).toHaveBeenCalledWith(
'title',
'Generated Title'
)
expect(mockEditorFacade.updateRecordField).toHaveBeenCalledWith(
expect(mockEditorFacade.updateRecordField).not.toHaveBeenCalledWith(
'abstract',
'Generated abstract.'
expect.anything()
)
expect(mockEditorFacade.updateRecordField).toHaveBeenCalledWith(
'keywords',
expect.anything()
)
expect(mockEditorFacade.updateRecordField).toHaveBeenCalledWith(
expect(mockEditorFacade.updateRecordField).not.toHaveBeenCalledWith(
'topics',
expect.anything()
)
Expand Down
Loading
Loading