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
449 changes: 449 additions & 0 deletions cms-ui/apps/editor-ui/COPILOT.md

Large diffs are not rendered by default.

268 changes: 268 additions & 0 deletions cms-ui/apps/editor-ui/e2e/copilot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { Page as CmsPage } from '@gentics/cms-models';
import {
EntityImporter,
loginWithForm,
navigateToApp,
NODE_MINIMAL,
PAGE_ONE,
TestSize,
} from '@gentics/e2e-utils';
import { expect, Locator, Page, test } from '@playwright/test';
import { AUTH } from './common';
import { openPageForEditing, selectNode } from './helpers';

/*
* The Content Copilot is feature-flagged via a JSON file the customer drops
* into `{ui-conf}/copilot.json`. The CI image obviously does not ship one,
* so every test here intercepts the request and serves a tailored payload
* (or a 404 when the test wants the disabled state).
*
* Crucial: the route MUST be installed BEFORE `navigateToApp`, because the
* fetch is kicked off by `AppComponent.ngOnInit()` right after bootstrap.
* That is why the navigation/login/select-node trio is wrapped in
* `openEditorWithCopilot()` rather than living in a generic `beforeEach`
* the way other suites (e.g. page-editing.spec.ts) handle it.
*
* We deliberately do NOT assert on translated UI strings — UI language is
* initially derived from the browser/system, so the actual text could be
* either German or English. Tests assert against stable hooks instead:
* `data-action`, `data-id`, structural classes.
*/

const COPILOT_CONFIG_URL_PATTERN = /\/ui-conf\/copilot\.json/;

const JSON_DISABLED = JSON.stringify({ enabled: false, actions: [] });
const JSON_ENABLED_NO_ACTIONS = JSON.stringify({ enabled: true, actions: [] });
const JSON_ENABLED_WITH_ACTIONS = JSON.stringify({
enabled: true,
actions: [
{
id: 'summarize',
labelI18n: { de: 'Zusammenfassen', en: 'Summarise' },
icon: 'lightbulb',
descriptionI18n: { de: 'Eine kurze Zusammenfassung', en: 'A short summary' },
},
{
id: 'rewrite-tone',
labelI18n: { de: 'Tonalität anpassen', en: 'Adjust tone' },
icon: 'edit_note',
},
],
});

/** Stubs the customer copilot.json endpoint for the lifetime of the page. */
async function stubCopilotConfig(page: Page, body: string | null): Promise<void> {
await page.route(COPILOT_CONFIG_URL_PATTERN, (route) => {
if (body === null) {
return route.fulfill({ status: 404, body: 'Not Found' });
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body,
});
});
}

/** Navigation + login + node selection — all the steps that other suites do
* in `beforeEach`, but invoked AFTER the JSON stub so the Copilot bootstrap
* fetch sees the per-test response. */
async function openEditorWithCopilot(
page: Page,
importer: EntityImporter,
body: string | null,
): Promise<void> {
await stubCopilotConfig(page, body);
await navigateToApp(page);
await loginWithForm(page, AUTH.admin);
await selectNode(page, importer.get(NODE_MINIMAL).id);
}

function copilotButton(page: Page): Locator {
return page.locator('content-frame gtx-editor-toolbar [data-action="copilot"]');
}

function copilotSidebar(page: Page): Locator {
return page.locator('gtx-copilot-sidebar .copilot-sidebar');
}

function copilotEmptyState(page: Page): Locator {
return copilotSidebar(page).locator('.copilot-empty-state');
}

function copilotActionItem(page: Page, id: string): Locator {
return copilotSidebar(page).locator(`.copilot-action-item[data-id="${id}"]`);
}

test.describe('Content Copilot', () => {

const IMPORTER = new EntityImporter();

test.beforeAll(async ({ request }) => {
await test.step('Client Setup', async () => {
IMPORTER.setApiContext(request);
await IMPORTER.clearClient();
});

await test.step('Test Bootstrapping', async () => {
await IMPORTER.cleanupTest();
await IMPORTER.bootstrapSuite(TestSize.MINIMAL);
});
});

test.beforeEach(async ({ request, context }) => {
await test.step('Client Setup', async () => {
IMPORTER.setApiContext(request);
await context.clearCookies();
await IMPORTER.clearClient();
});

await test.step('Common Test Setup', async () => {
await IMPORTER.cleanupTest();
await IMPORTER.setupTest(TestSize.MINIMAL);
});
});

test.describe('Toolbar button visibility', () => {

test('button stays hidden when copilot.json is missing (404)', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, null);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});

test('button stays hidden when copilot.json has enabled: false', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_DISABLED);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});

test('button appears when copilot.json has enabled: true and a page is in edit mode', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(copilotButton(page)).toBeVisible();
});

test('button stays hidden as long as no page is opened in edit mode', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
// Deliberately NOT opening a page — we should still be on the
// folder list, where there is no editor toolbar at all.
await expect(copilotButton(page)).toHaveCount(0);
});

test('button stays hidden when invalid JSON is served', async ({ page }) => {
// Missing required `id` on the action — the loader falls back to
// the disabled default, so the button must NOT appear.
const invalid = JSON.stringify({
enabled: true,
actions: [{ labelI18n: { en: 'incomplete' } }],
});
await openEditorWithCopilot(page, IMPORTER, invalid);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});
});

test.describe('Sidebar interaction', () => {

test('clicking the toolbar button opens the sidebar', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

// Closed by default — verifying the precondition guards us against
// a bug where the sidebar mounts in the open state.
await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);

await copilotButton(page).click();

await expect(copilotSidebar(page)).toHaveClass(/is-open/);
});

test('clicking the close icon collapses the sidebar again', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();
await expect(copilotSidebar(page)).toHaveClass(/is-open/);

await copilotSidebar(page).locator('[data-action="copilot-close"]').click();

await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);
});

test('clicking the toolbar button a second time toggles the sidebar closed', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();
await expect(copilotSidebar(page)).toHaveClass(/is-open/);

await copilotButton(page).click();

await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);
});
});

test.describe('Sidebar contents', () => {

test('shows the empty-state when actions: []', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

await expect(copilotEmptyState(page)).toBeVisible();
await expect(copilotSidebar(page).locator('.copilot-action-item')).toHaveCount(0);
});

test('renders one card per configured action with correct id and label slot', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_WITH_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

// Empty state must NOT be on the page once we have actions.
await expect(copilotEmptyState(page)).toHaveCount(0);

const items = copilotSidebar(page).locator('.copilot-action-item');
await expect(items).toHaveCount(2);

// Identify cards by their stable `data-id` and assert the
// label-container is rendered with non-empty content. We do
// NOT assert on the literal text — the actual UI language
// depends on the browser locale of the runner, which would
// make tests flaky in CI.
const summarize = copilotActionItem(page, 'summarize');
await expect(summarize).toBeVisible();
await expect(summarize.locator('.copilot-action-label')).not.toBeEmpty();
await expect(summarize.locator('.copilot-action-description')).not.toBeEmpty();

const rewrite = copilotActionItem(page, 'rewrite-tone');
await expect(rewrite).toBeVisible();
await expect(rewrite.locator('.copilot-action-label')).not.toBeEmpty();
// No descriptionI18n on this one — the description span must
// therefore not be rendered at all.
await expect(rewrite.locator('.copilot-action-description')).toHaveCount(0);
});

test('chat input is rendered but disabled in this UI iteration', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, JSON_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

const textarea = copilotSidebar(page).locator('gtx-textarea.copilot-chat-textarea');
await expect(textarea).toBeVisible();
// gtx-textarea wraps a native <textarea> — the `disabled` attribute
// ends up on that inner element, hence the descendant lookup.
await expect(textarea.locator('textarea')).toBeDisabled();
});
});
});
10 changes: 10 additions & 0 deletions cms-ui/apps/editor-ui/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@
"link_checker_no_content": "Keine defekten Links vorhanden",
"constructs_empty": "Keine TagTypen einsetzbar/verfügbar"
},
"copilot": {
"button_label": "Copilot",
"button_tooltip": "Content Copilot öffnen",
"sidebar_title": "Content Copilot",
"close_label": "Copilot schließen",
"send_label": "Senden",
"input_placeholder": "Frage stellen oder Anweisung geben …",
"preview_hint": "Vorschau – Aktionen werden in Kürze freigeschaltet.",
"empty_state_message": "Keine Aktionen konfiguriert. Aktionen können über die Datei „copilot.yml“ in der UI-Konfiguration hinzugefügt werden."
},
"editor": {
"alert_center_title": "Warnungszentrale",
"alert_notification_singular_label": "Warnungszentrale: {{ count }} ungelöstes Problem wurde gefunden.",
Expand Down
10 changes: 10 additions & 0 deletions cms-ui/apps/editor-ui/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@
"link_checker_no_content": "No broken Links detected",
"constructs_empty": "No Constructs insertable/available"
},
"copilot": {
"button_label": "Copilot",
"button_tooltip": "Open Content Copilot",
"sidebar_title": "Content Copilot",
"close_label": "Close Copilot",
"send_label": "Send",
"input_placeholder": "Ask a question or give an instruction …",
"preview_hint": "Preview – actions will be enabled shortly.",
"empty_state_message": "No actions configured. Actions can be added via the \"copilot.yml\" file in the UI configuration."
},
"editor": {
"alert_center_title": "Alert Center",
"alert_notification_singular_label": "Alert Center: {{ count }} unresolved problem was found",
Expand Down
8 changes: 8 additions & 0 deletions cms-ui/apps/editor-ui/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { NavigationService } from './core/providers/navigation/navigation.servic
import { PermissionService } from './core/providers/permissions/permission.service';
import { UserSettingsService } from './core/providers/user-settings/user-settings.service';
import { UsersnapService } from './core/providers/usersnap/usersnap.service';
import { CopilotConfigService } from './copilot';
import { EmbeddedToolsService } from './embedded-tools/providers/embedded-tools/embedded-tools.service';
import { ChipSearchBarConfigService } from './shared/providers/chip-search-bar-config/chip-search-bar-config.service';
import { UIOverridesService } from './shared/providers/ui-overrides/ui-overrides.service';
Expand Down Expand Up @@ -148,6 +149,7 @@ export class AppComponent implements OnInit {
private router: Router,
private uiActions: UIActionsService,
private uiOverrides: UIOverridesService,
private copilotConfig: CopilotConfigService,
private userSettings: UserSettingsService,
private contentRepositoryActions: ContentRepositoryActionsService,
private usersnapService: UsersnapService,
Expand All @@ -172,6 +174,12 @@ export class AppComponent implements OnInit {
// Load customer-specific UI overrides
this.uiOverrides.loadCustomerConfiguration();

// Load Content Copilot configuration. Same lifecycle as the
// UI-overrides above — must run at app bootstrap (not from a
// lazy-loaded module) so the toolbar button is correctly
// gated by the customer's `copilot.yml` from the first paint.
this.copilotConfig.load();

this.maintenanceMode.refreshPeriodically(30000);
this.maintenanceMode.refreshOnLogout();
this.maintenanceMode.validateSessionWhenActivated();
Expand Down
2 changes: 2 additions & 0 deletions cms-ui/apps/editor-ui/src/app/common/models/ui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ export interface UIState {
constructFavourites: string[];
tagEditorOpen: boolean;
nodesLoaded: boolean;
/** Whether the Content Copilot sidebar is currently expanded. */
copilotOpen: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,11 @@
<div class="hidden-file-picker" #filePickerWrapper>
<gtx-file-picker></gtx-file-picker>
</div>

<!--
Content Copilot drawer. Always rendered so the slide-in/out
transition has a stable host node; visibility is controlled by
CopilotStateService inside the component itself.
-->
<gtx-copilot-sidebar></gtx-copilot-sidebar>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@
</gtx-button>
}

@if (buttons.copilot) {
<gtx-button
class="primary-action copilot-button"
size="small"
flat
type="secondary"
data-action="copilot"
[class.is-active]="copilotOpen"
[attr.data-active]="copilotOpen"
[title]="'copilot.button_tooltip' | gtxI18n"
(click)="toggleCopilot()"
>
<icon left>lightbulb</icon>
<span class="copilot-button-label">{{ 'copilot.button_label' | gtxI18n }}</span>
</gtx-button>
}

@if (showSave) {
<gtx-split-button
class="primary-action save-button"
Expand Down
Loading