Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,38 @@
pull_request:

jobs:
schemer-responsive:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Chromium
run: npx playwright install --with-deps chromium

- name: Schemer viewport regression tests
env:
CI: true
run: npm run test:schemer:responsive

- name: Upload browser failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: schemer-responsive-failures
path: test-results
if-no-files-found: warn

security-audit:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ yarn-error.log
testem.log
/typings
.eslintcache
/test-results
/playwright-report


# System files
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ Additionally, this library exports multiple dialogs and helpers (see “Public A

# Installation (Angular)

## Schemer viewport support

The Schemer supports container widths of **320 CSS pixels and above**, including
375, 768, 1024 and 1440 px. At narrow widths the variable navigation moves above
the editor, editor cards stack and toolbars wrap. The variable list and editor
scroll vertically independently so their actions remain reachable. The layout
uses the component's container width, including when embedded in a Verona iframe.
Below 320 px the component retains its minimum width; the host must provide
horizontal scrolling.

Run the browser regression tests with `npm run test:schemer:responsive` after
`npx playwright install chromium`. For a visible local browser run, append
`-- --headed`. The tests start their own Schemer server on port 4210.

## Prerequisites

This library is built for modern Angular and requires:
Expand Down
176 changes: 176 additions & 0 deletions e2e/schemer-responsive.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/* Browser interactions in a scenario must run sequentially. */
/* eslint-disable no-await-in-loop, no-restricted-syntax */
const { test, expect } = require('@playwright/test');
const fs = require('node:fs/promises');

const variables = Array.from({ length: 30 }, (_, i) => ({
id: `v_${i + 1}`,
type: 'string',
format: '',
multiple: true,
nullable: false,
values: [],
valuePositionLabels: []
}));

const scheme = {
version: '3.4',
variableCodings: [{
id: 'v_1',
alias: 'v_1',
label: 'Testvariable',
page: '',
sourceType: 'BASE',
processing: [],
codeModel: 'MANUAL_AND_RULES',
manualInstruction: '<p>Allgemeine Kodieranweisung</p>',
codes: [{
id: 1,
type: 'FULL_CREDIT',
score: 1,
label: '',
manualInstruction: '<p>Richtige Antwort</p>',
ruleSetOperatorAnd: false,
ruleSets: [0, 1].map(valueArrayPos => ({
valueArrayPos,
ruleOperatorAnd: false,
rules: [
{ method: 'MATCH', parameters: ['richtig'] },
{ method: 'NUMERIC_FULL_RANGE', parameters: ['1', '5'] }
]
}))
}]
}]
};

async function loadJson(page, menuLabel, name, data) {
await page.locator('schemer-standalone-menu button').click();
const chooser = page.waitForEvent('filechooser');
await page.getByRole('menuitem', { name: menuLabel, exact: true }).click();
await (await chooser).setFiles({
name,
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(data))
});
}

async function loadEditor(page) {
await page.goto('/');
await loadJson(page, 'Variablenliste laden', 'variables.json', variables);
await expect(page.locator('.var-list-entry')).toHaveCount(30);
await loadJson(page, 'Antwortschema laden', 'scheme.json', scheme);
await expect(page.locator('single-code')).toHaveCount(1);
}

async function expectContained(page) {
const overflow = await page.locator('iqb-schemer').evaluate(root => {
const bounds = root.getBoundingClientRect();
return [...root.querySelectorAll('button, mat-form-field, mat-card, mat-button-toggle-group')]
.filter(element => element.getClientRects().length > 0)
.map(element => {
const rect = element.getBoundingClientRect();
return {
tag: element.tagName,
text: element.textContent.trim(),
left: rect.left,
right: rect.right
};
})
.filter(rect => rect.left < bounds.left - 1 || rect.right > bounds.right + 1);
});
expect(overflow).toEqual([]);
const clippedToggles = await page.locator('mat-button-toggle-group').evaluateAll(groups => groups
.filter(group => group.getClientRects().length > 0)
.map(group => ({
text: group.textContent.trim(),
width: group.clientWidth,
contentWidth: group.scrollWidth
}))
.filter(group => group.contentWidth > group.width + 1));
expect(clippedToggles).toEqual([]);
const editorOverflow = await page.locator('var-coding')
.evaluate(element => element.scrollWidth - element.clientWidth);
expect(editorOverflow).toBeLessThanOrEqual(1);
const documentOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(documentOverflow).toBeLessThanOrEqual(1);
}

async function expectActionReachable(locator) {
await locator.scrollIntoViewIfNeeded();
await expect(locator).toBeInViewport();
await locator.click({ trial: true });
}

for (const width of [320, 375, 768, 1011, 1024, 1440]) {
test(`navigation, editor and actions remain usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await loadEditor(page);
await expectContained(page);

const nav = await page.locator('.navi-bar').boundingBox();
const editor = await page.locator('var-coding').boundingBox();
if (width <= 650) {
expect(nav.y + nav.height).toBeLessThanOrEqual(editor.y + 1);
} else {
expect(nav.x + nav.width).toBeLessThanOrEqual(editor.x + 1);
}

await page.locator('.var-list-entry').filter({ hasText: /^\s*v_9\s*$/ }).click();
await expect(page.locator('single-code')).toHaveCount(0);
await page.locator('.var-list-entry').filter({ hasText: /^\s*v_1\s*$/ }).click();
await expect(page.locator('single-code')).toHaveCount(1);
await expectActionReachable(page.locator('.var-list-buttons button').last());
await expectActionReachable(page.locator('.coding-actions button').last());
await expectActionReachable(page.locator('.add-code-actions button').last());
await expectActionReachable(page.locator('single-code .delete-code'));
await expectActionReachable(page.locator('code-instruction button').first());
const andOperator = page.locator('code-rule-list').first().getByRole('radio', { name: 'UND', exact: true });
await andOperator.click();
await expect(andOperator).toBeChecked();

// Each code model must fit, including returning to the two-card view.
for (const modelIndex of [1, 2, 0]) {
await page.locator('.coding-actions mat-button-toggle').nth(modelIndex).click();
await expectContained(page);
}

// Exercise actions that used to be clipped, and confirm the result can be saved.
await page.locator('single-code .copy-code').click();
await page.locator('codes-title button').first().click();
await page.getByRole('button', { name: 'Trotzdem einfügen', exact: true }).click();
await expect(page.locator('single-code')).toHaveCount(2);
await page.locator('single-code .delete-code').last().click();
await expect(page.locator('single-code')).toHaveCount(1);

await page.locator('schemer-standalone-menu button').click();
const download = page.waitForEvent('download');
await page.getByRole('menuitem', { name: 'Antwortschema speichern', exact: true }).click();
const savedFile = await download;
expect(savedFile.suggestedFilename()).toBe('coding-scheme.json');
const savedScheme = JSON.parse(await fs.readFile(await savedFile.path(), 'utf8'));
const savedVariable = savedScheme.variableCodings.find(variable => variable.id === 'v_1');
expect(savedVariable.codeModel).toBe('MANUAL_AND_RULES');
expect(savedVariable.codes).toHaveLength(1);
expect(savedVariable.codes[0].ruleSets).toHaveLength(2);
expect(savedVariable.codes[0].ruleSets[0].ruleOperatorAnd).toBe(true);
});
}

test('a narrow container reflows inside a wide host and after resizing', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await loadEditor(page);
for (const width of [375, 768, 1011, 1024, 1440]) {
await page.locator('iqb-schemer').evaluate((element, containerWidth) => {
element.style.setProperty('width', `${containerWidth}px`);
}, width);
await expectContained(page);
const nav = await page.locator('.navi-bar').boundingBox();
const editor = await page.locator('var-coding').boundingBox();
if (width === 375) {
expect(nav.y + nav.height).toBeLessThanOrEqual(editor.y + 1);
} else {
expect(nav.x + nav.width).toBeLessThanOrEqual(editor.x + 1);
}
await expectActionReachable(page.locator('single-code .delete-code'));
}
});
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"npm_publish": "npm run npm_publish_cc && npm run npm_publish_elements",
"npm_publish_cc": "cd dist/ngx-coding-components && npm publish --access public",
"npm_publish_elements": "cd dist/coding-components-elements && npm publish --access public",
"lint": "eslint -c .eslintrc.cjs src projects --ext .ts,.js --cache --ignore-pattern '**/karma.conf.js'",
"lint:fix": "eslint -c .eslintrc.cjs src projects --ext .ts,.js --fix --cache --ignore-pattern '**/karma.conf.js'",
"lint": "eslint -c .eslintrc.cjs src projects e2e --ext .ts,.js --cache --ignore-pattern '**/karma.conf.js'",
"lint:fix": "eslint -c .eslintrc.cjs src projects e2e --ext .ts,.js --fix --cache --ignore-pattern '**/karma.conf.js'",
"audit:prod": "npm audit --omit=dev",
"test:ajv-override": "node --test scripts/ajv-override.test.js",
"validate:scheme": "node scripts/validate-coding-scheme.js",
Expand All @@ -31,7 +31,8 @@
"test:schemer:watch": "ng test schemer",
"test:schemer:coverage": "ng test schemer --watch=false --browsers=ChromeHeadless --code-coverage",
"test:coverage": "npm run test:cc:coverage && npm run test:schemer:coverage",
"test:scheme": "node --test scripts/validate-coding-scheme.test.js"
"test:scheme": "node --test scripts/validate-coding-scheme.test.js",
"test:schemer:responsive": "playwright test --config playwright.schemer.config.cjs"
},
"dependencies": {
"@angular/animations": "^21.2.23",
Expand Down Expand Up @@ -94,6 +95,7 @@
"@angular/compiler-cli": "^21.2.23",
"@iqb/eslint-config": "^2.2.0",
"@iqbspecs/validate-json": "0.4.0",
"@playwright/test": "^1.63.0",
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
Expand Down
21 changes: 21 additions & 0 deletions playwright.schemer.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
testDir: './e2e',
testMatch: 'schemer-responsive.spec.js',
fullyParallel: true,
workers: 2,
use: {
browserName: 'chromium',
headless: !!process.env.CI,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the default browser run headless

When CI is unset, this expression evaluates to false, so the documented npm run test:schemer:responsive command launches a headed browser and fails on local headless/SSH environments without a display. This also contradicts README lines 70–72, which say --headed should be appended for a visible run; omit this override or default it to headless so the base command works everywhere.

Useful? React with 👍 / 👎.

baseURL: 'http://127.0.0.1:4210',
screenshot: 'only-on-failure',
trace: 'retain-on-failure'
},
webServer: {
command: 'npx ng serve schemer --host 127.0.0.1 --port 4210',
url: 'http://127.0.0.1:4210',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { UserRoleType } from './services/schemer.service';
</mat-menu>
`,
styles: [
'.mat-mdc-fab {z-index: 999; position: absolute; top: -8px; right: -8px}'
'.mat-mdc-fab {z-index: 999; position: absolute; top: 4px; right: 8px}'
],
standalone: true,
imports: [
Expand Down
Loading
Loading