Client Development
Introduction & General Principles
General
The Artemis client is an Angular project. Follow the official Angular style guide: https://angular.dev/style-guide
Key principles:
- Use lazy loading to keep the initial bundle size small.
- Prioritize code quality and test coverage. Reuse code and avoid duplication. Write meaningful tests.
Angular best practices
-
Use standalone components instead of Angular modules: https://angular.dev/reference/migrations/standalone
-
Use signals for reactive state management. Signals allow Angular to granularly track how and where state is used, enabling optimized rendering updates: https://angular.dev/guide/signals
-
Find out more in the following guide: https://blog.angular-university.io/angular-signal-components
-
Use
input()/input.required()instead of@Input(), andoutput()instead of@Output():// Don't - legacy decorators are forbidden@Input() myInput: string;@Output() myOutput = new EventEmitter<string>();// Do - use signal-based APIsmyInput = input<string>();myRequiredInput = input.required<string>();myOutput = output<string>(); -
Use
viewChild()/viewChild.required()instead of@ViewChild(), andviewChildren()instead of@ViewChildren():// Don't - legacy decorators are forbidden@ViewChild('myRef') myRef?: MyComponent;@ViewChildren(MyComponent) myRefs?: QueryList<MyComponent>;// Do - use signal-based query APIsmyRef = viewChild<MyComponent>('myRef');myRequiredRef = viewChild.required<MyComponent>('myRef');myRefs = viewChildren<MyComponent>(MyComponent); -
Use
signal(),computed(), andeffect()for component state instead of plain class fields:// Don't - plain fields don't integrate with Angular's change detectioncount = 0;doubled = this.count * 2;// Do - signals enable fine-grained reactivitycount = signal(0);doubled = computed(() => this.count() * 2); -
A
computed(),linkedSignal(),effect()orafterRenderEffect()must read at least one signal, otherwise it runs once and never again. Either a signal read is missing (countinstead ofcount()), or the value is a constant and belongs in a plainreadonlyfield. The@angular-eslint/reactive-context-must-read-signalrule reports this, and@angular-eslint/computed-must-returnreports acomputed()that returns nothing. -
Use the
inject()function for dependency injection instead of constructor injection: https://angular.dev/reference/migrations/inject-function -
Declare every
inject()field before any other class member. Fields initialize in the order they are declared, so a getter or method that an earlier field initializer calls would readundefinedfrom a service declared further down, and TypeScript does not catch that indirection. The@angular-eslint/inject-at-toprule enforces this. -
Declare an application-wide service with
@Service()instead of@Injectable({ providedIn: 'root' }). It is the same provider, it is whatng generate serviceemits, and it rejects constructor injection at compile time. The autofixable@angular-eslint/prefer-service-decoratorrule enforces this, and@Injectablestays only for a service with other provider metadata. A pipe that is also injected as a service is the exception the rule does not know about: it still reports the pipe, but@Service()cannot share a class with@Pipe, so its autofix breaks the build with NG1006. Only the AOT compiler (ng build) catches that, not Vitest. Keep@Injectableon such a pipe with a justified line-level disable, and do not run the autofix on it.// Don't - @Injectable for a root service, and the injected field after another member@Injectable({ providedIn: 'root' })export class CourseService {private readonly resourceUrl = 'api/core/courses';private readonly http = inject(HttpClient);}// Do@Service()export class CourseService {private readonly http = inject(HttpClient);private readonly resourceUrl = 'api/core/courses';} -
Prefer
OnPushchange detection for components: https://blog.angular-university.io/onpush-change-detection-how-it-works -
See the migration example: https://github.com/ls1intum/Artemis/pull/9299
UI components
Choose the smallest stable abstraction that meets the requirement:
- Use semantic native HTML when the platform already supplies the required behavior.
- Use an existing TUM AET UI component for a reusable control or visual pattern.
- If a reusable pattern is missing, add or evolve a TUM AET UI component around native HTML, Angular Aria, and stable Angular CDK primitives. The pull request must include a concrete Artemis consumer; a second application is not required.
- Keep domain-specific composition, routing, requests, and application translations in Artemis. Compose those features from TUM AET UI primitives instead of exporting application policy as a package API.
- Use PrimeNG only when the TUM AET UI gap cannot reasonably be closed in the same change. State the missing capability and migration path in the pull request, and isolate the fallback so it can be replaced.
Do not reimplement keyboard navigation, focus management, overlays, selection, or virtual scrolling when native HTML, Angular Aria, or Angular CDK already provides the behavior. Follow the TUM AET UI package guide for ownership, public API, stories, and validation.
Points inputs
Every field in which a user enters points, bonus points or credits uses jhi-points-input (PointsInputComponent in
app/shared-ui/tum-aet-ui-integration/points-input), never a native <input type="number"> or a PrimeNG number input. It wraps
tumaet-ui-input-number with one fixed setup, so that all of them accept and show a number in the same way: up to three fraction
digits (a longer fraction is cut off while typing), . and , as decimal separator whatever the browser language, and no digit
grouping. It works with ngModel, formControlName and formControl.
Bind [min] and [max] on the component: a native min or max attribute does not validate a custom control. The bounds are
applied when the field is left and reported as min and max errors of the control as long as the typed value violates them.
Pass inputId for a <label for> and for end-to-end locators, because the id belongs to the inner <input>.
Inputs for numbers that are not points, such as percentages, durations, counts and sizes, are not points inputs. They use
tumaet-ui-input-number directly, with the integer or fraction setup that fits their meaning.
Zoneless change detection & signal-based state
The Artemis client runs zoneless (provideZonelessChangeDetection()). zone.js has been removed from the build, and NgZone is banned (a no-restricted-imports ESLint rule fails the build on any import { NgZone } from '@angular/core'). This changes the single most important rule about how you write components.
Why. With zone.js, Angular re-ran change detection after every async event (any setTimeout, XHR, event, promise) and re-checked the whole component tree. Without it, Angular only schedules a re-render when something it can actually observe changes: a signal read in a template updates, an async pipe emits, a template event handler (click) runs, or code explicitly calls markForCheck(). A plain field that you reassign inside an async callback changes nothing Angular is watching, so the template keeps showing the old value. Background: Angular zoneless guide, Angular signals guide.
The bug class to avoid. A plain, template-bound field mutated inside an async callback (HTTP subscribe, WebSocket, setTimeout/setInterval, promise then, an observer) will silently not re-render under zoneless:
// Don't - plain field set in an async callback never triggers a re-render under zoneless
@Component({ template: `<span>{{ title }}</span>` })
export class ExampleComponent implements OnInit {
title = '';
ngOnInit() {
this.service.getTitle().subscribe((t) => (this.title = t)); // view stays empty
}
}
// Do - a signal write schedules change detection automatically
@Component({ template: `<span>{{ title() }}</span>` })
export class ExampleComponent implements OnInit {
readonly title = signal('');
ngOnInit() {
this.service.getTitle().subscribe((t) => this.title.set(t)); // view updates
}
}
This bug is invisible to unit tests — they call fixture.detectChanges() manually — so passing Vitest does not prove zoneless-correctness. The AOT build (pnpm run webapp:build) type-checks templates and catches most signal-vs-value slips (e.g. binding the signal title where a string input is expected), but it cannot catch every missing (); the E2E (Playwright) suite is the real arbiter for "does it actually re-render in the running app".
Prefer toSignal() for Observable-backed state. When a value comes straight from an Observable (an HTTP call, a store selector, a service stream) and the template only reads it, convert the stream to a signal with toSignal() instead of subscribing and calling .set() by hand — it wires up change detection and unsubscribes automatically:
// instead of: subscribe + this.title.set(...) in ngOnInit
readonly title = toSignal(this.service.getTitle(), { initialValue: '' });
Derive, don't recompute — use computed. Any value that is a pure function of other signals must be a computed, never a field you keep in sync by hand. It is lazy, memoised, and can never go stale:
readonly exercise = input.required<Exercise>();
readonly isProgramming = computed(() => this.exercise().type === ExerciseType.PROGRAMMING);
readonly maxPoints = computed(() => this.exercise().maxPoints ?? 0);
Update collections and objects immutably. A signal only notifies when its reference changes. Mutating an array/Map/object held in a signal in place changes nothing Angular observes — always write a fresh reference:
// Don't - in-place mutation; the signal reference is unchanged, no re-render
this.items().push(newItem);
this.lookup().set(key, value);
// Do - replace with a new reference
this.items.update((items) => [...items, newItem]);
this.lookup.update((map) => new Map(map).set(key, value));
Two-way binding to deep entity objects ([(ngModel)]). A signal cannot back a [(ngModel)] on a nested object property directly. Use a getter/setter facade over a backing signal — the setter publishes through the signal (scheduling change detection) while the getter keeps the plain API the template and ngModel expect:
private readonly _course = signal<Course>(new Course());
get course(): Course {
return this._course();
}
set course(course: Course) {
this._course.set(course); // async assignment routes through the setter → re-render
}
Inputs, model, two-way state, and view queries are signals too. Use input() / input.required(), model() for two-way bindings, viewChild() / viewChildren() / contentChild(), and linkedSignal() for a derived value that can also be set locally (see the decorator rules above).
Reach for effect() last. Effects are for syncing signal state out to non-reactive APIs (imperative third-party libraries, the DOM, logging) — not for deriving state (use computed) and not for general reactivity. An effect that reads a signal and also writes another signal it reads will re-trigger itself forever; if an effect must both read and write, read its triggers explicitly and wrap the writes in untracked(). To run code once after the view has rendered (e.g. measuring the DOM), use afterNextRender() rather than setTimeout — it self-schedules a change-detection tick under zoneless.
Never navigate, and avoid writing template-bound signals, from an effect() — it can crash the client. Both are imperative side effects that feed back into change detection and can form an infinite loop:
Router.navigate()/navigateByUrl()inside an effect is the most dangerous. Navigation destroys and re-creates components and rewrites their signals, which re-triggers the effect, which navigates again. This crashed the online code editor: an incoming result replaced a participation object (same id, new reference), re-running a navigationeffect, which re-navigated and re-created the whole subtree — flooding the server with thousands of requests until the tab ran out of memory. Drive navigation from explicit user actions, or from a route guard or resolver that returns a redirect (see Redirecting from guards and resolvers). If reactive navigation is genuinely unavoidable, depend on a stablecomputed()key (so a replaced object with an unchanged id does not re-run the effect) and wrap thenavigate(...)call inuntracked(). ThelocalRules/no-navigation-in-effectESLint rule enforces this — navigation in an effect that is not wrapped inuntracked()is an error.- Writing a signal in an effect carries the same loop risk (an effect that writes a signal it also reads re-triggers itself forever). Prefer
computed()for derived state; if a write is truly necessary, never write a signal the effect reads and wrap the write inuntracked(). This case cannot be linted reliably (.set()/.update()are indistinguishable fromMap/Setmutations at the syntax level), so it is on the author and reviewer to avoid it.
// Don't - navigate directly in an effect: object-reference churn re-runs it → infinite re-navigation
effect(() => {
const participation = this.studentParticipation();
this.router.navigate(['code-editor', participation.id]);
});
// Do - depend on a stable identity key; navigate untracked so only the key re-runs the effect
private readonly targetKey = computed(() => `${this.exercise().id}:${this.studentParticipation()?.id}`);
constructor() {
effect(() => {
if (!this.targetKey()) return;
untracked(() => this.router.navigate(['code-editor', this.studentParticipation()!.id]));
});
}
The handful of legitimate non-signal cases (an impure self-updating pipe such as a time-ago pipe; CD on dynamically injected components living outside the template tree) require an explicit, commented justification. If you ever think you need NgZone, you don't — it is a no-op under zoneless and is banned.
| Member | Signal API |
|---|---|
| Local mutable state read by the template | signal() + .set() / .update() |
| Value derived from other signals/inputs | computed() |
| Component input | input() / input.required() |
| Two-way bound input | model() |
| Derived value that is also locally settable | linkedSignal() |
@ViewChild / @ViewChildren / @ContentChild | viewChild() / viewChildren() / contentChild() |
| Sync state to a non-reactive API (rare) | effect() |
| Constant (icon, enum, injected service) | plain readonly field — no signal needed |
Reference migration: PR #12872 (zoneless migration). Further reading: Angular signals, zoneless, signal queries, linkedSignal, signal-input migration.
Reacting to input changes & lifecycle hooks
Prefer reactive primitives over the ngOnChanges lifecycle hook in signal-based components.
Pick the right reactive primitive. Angular's guidance is computed first, effect last — effects are for syncing signals to non-reactive APIs, not for general reactivity:
Old ngOnChanges shape | Idiomatic signal replacement |
|---|---|
| Recompute a value derived from inputs/state | computed() — lazy, memoised, cannot go stale |
| Derived value that can also be set locally | linkedSignal() |
| Genuine side effect (subscribe, imperative / 3rd-party API, DOM) | effect() — use sparingly; it is the last API to reach for |
Need SimpleChanges.previousValue or isFirstChange() | exceptional ngOnChanges use with a justified line-level lint disable; signals do not expose these directly |
| Logic that must run before child components initialise | exceptional ngOnChanges use with a justified line-level lint disable; it runs before ngOnInit, while an effect() runs afterwards |
// Don't - derive state imperatively in ngOnChanges (has to be kept in sync by hand)
@Component({/* ... */})
export class ExampleComponent implements OnChanges {
value = input.required<number>();
doubled = 0;
ngOnChanges() {
this.doubled = this.value() * 2;
}
}
// Do - derive declaratively; it can never go stale
@Component({/* ... */})
export class ExampleComponent {
value = input.required<number>();
doubled = computed(() => this.value() * 2);
}
An ESLint rule (localRules/prefer-signal-reactivity-over-ngonchanges) errors on ngOnChanges across src/main/webapp/app, packages/tum-aet-ui/src/lib, and src/test/javascript, including specs. The checked paths are free of the hook, and error severity prevents an accidental reintroduction from passing CI.
The rule deliberately does not require an @Component / @Directive decorator. Angular invokes ngOnChanges on the component instance, so a hook inherited from an undecorated base class runs exactly like one declared on the component — requiring the decorator would leave that route open. Type-only declarations (interface X { ngOnChanges(...) }), object-literal properties, and static ngOnChanges helpers are not flagged, because Angular never calls those.
Use computed() for derived state and effect() for genuine side effects. For a rare case that genuinely needs ngOnChanges (the bottom two rows above), add a detailed comment explaining why a signal-based replacement is not possible and use a narrowly scoped eslint-disable-next-line localRules/prefer-signal-reactivity-over-ngonchanges; do not blanket-disable the rule for a file.
ngOnInit and ngOnDestroy are not affected by signals — they fire regardless of the input mechanism and remain valid. For teardown, prefer DestroyRef + takeUntilDestroyed() or an effect() (which cleans itself up) over manual ngOnDestroy bookkeeping where practical; the localRules/enforce-cleanup-on-destroy rule already guards subscription cleanup.
Naming Conventions
- Use PascalCase for type and enum names.
- Do not prefix interfaces with "I".
- Use camelCase for functions, properties, and local variables.
- Use SCREAMING_SNAKE_CASE for constants (readonly properties).
- Do not prefix private properties with "_".
- Use descriptive, whole words for names.
Type Usage
-
Do not export types/functions unless you need to share it across multiple components.
-
Do not introduce new types/values to the global namespace.
-
Shared types/interfaces should be defined in 'types.ts'.
-
Within a file, type definitions should come first.
-
Interfaces and types offer almost the same functionality. To ensure consistency, choose
interfaceovertypewhenever possible.// Dont dotype AngularLink = {text: string;routerLink: (string | number)[];};// Dointerface AngularLink {text: string;routerLink: (string | number)[];}// And this is also allowed (because interface is not possible here)type RouterLinkPart = string | number; -
Use strict typing to avoid type errors: Never use
any. -
Do not use anonymous data structures.
interface AngularLink {text: string;routerLink: (string | number)[];}// Do not do this because the type error will not be recognized during compile time.const link = { text: 'I am a Link', routerLink: 4 } as AngularLink;// Instead do this (it will throw a type error during compilation because '4' is not an array of strings)const link: AngularLink = { text: 'I am a Link', routerLink: '4' };
null and undefined
Use undefined. Never use null.
TypeScript strict mode
The client compiles with TypeScript "strict": true (plus noImplicitReturns, noImplicitOverride, noUnusedLocals, and noFallthroughCasesInSwitch). Among other things, strict enables strictPropertyInitialization, so every class field must be definitely assigned. When you add a field, resolve the initialization requirement deliberately — do not reflexively silence it with a definite-assignment assertion (!), which hides rather than answers the question of whether the field can be undefined.
Pick the first option that fits:
-
Give it a real default when one exists (preferred — it also removes latent
undefinedaccess):items: Foo[] = []; // not: items!: Foo[];isLoading = false; // not: isLoading!: boolean;counts = new Map<string, number>();Exception: if code relies on
if (this.items)/this.items?.to mean "not loaded yet", defaulting to an empty array (which is truthy) changes that behavior — make it optional instead. -
Make it optional (
?) when the field may legitimately be absent and its reads already tolerateundefined:private sub?: Subscription; // assigned in ngOnInit(), torn down with this.sub?.unsubscribe() -
Use a definite-assignment assertion (
!) only when the value is guaranteed to be assigned before it is read — by an Angular lifecycle hook, dependency injection, a required input, aviewChild, or a route resolver. Always add a short inline comment stating why:course!: Course; // set in ngOnInit() from the route data@ViewChild(Editor) editor!: EditorComponent; // available after view initA bare
!without a justifying comment is not acceptable.
DTO / model types. A data-transfer object that is only ever populated by deserialization of a server response (never new-ed, no methods, no instanceof checks) should be declared as an interface, not a class. Interfaces carry no initialization requirement and model a server payload honestly:
// Do
export interface GradeStepsDTO {
title: string;
gradeType: GradeType;
gradeSteps: GradeStepDTO[];
maxPoints?: number; // optional stays optional
}
Model classes that are actually instantiated or carry behavior (methods, static factories) stay classes; their externally-populated fields use ! together with a class-level comment noting the fields are assigned after construction.
General Assumptions
- Consider objects like Nodes, Symbols, etc. as immutable outside the component that created them. Do not change them.
- Consider arrays as immutable by default after creation.
Components & Template Guidelines
Components
In our project, we promote the creation of standalone components instead of using Angular modules.
A standalone component is a self-contained unit that encapsulates its own logic, view, and styles.
It doesn't directly depend on its parent or child components and can be reused in different parts of the application.
For existing components that are not standalone, we should aim to migrate them step by step.
This migration process should be done gradually and carefully, to avoid introducing bugs.
It's recommended to thoroughly test the component after each change to ensure it still works as expected.
Standalone components can be generated with the Angular CLI using ng g c <component-name> --standalone.
More info about standalone components: https://angular.dev/guide/components/importing#standalone-components
Templates
Use the built-in control flow blocks @if, @for and @switch, not the structural directives *ngIf, *ngFor and *ngSwitch.
Every @switch has a @default, which the @angular-eslint/template/require-switch-default rule enforces. When the cases cover every member of a union or an enum, write
@default never;. It renders nothing, and the strict template check fails as soon as a member is added without a matching case. Otherwise, for example when the value can
also be undefined, write an empty @default {} to show that rendering nothing is intended.
type ImportStep = 'upload' | 'review' | 'done';
readonly step = signal<ImportStep>('upload');
@switch (step()) {
@case ('upload') {
<p>Choose a file</p>
}
@case ('review') {
<p>Check the rows</p>
}
@case ('done') {
<p>Import finished</p>
}
@default never;
}
Bind styles with [style.prop], [style.prop.unit] or [style], not with NgStyle, and write a constant style as a static style attribute. The
@angular-eslint/template/prefer-style-binding rule enforces this. A [style] object is compared by reference, so replace the object instead of mutating it. It also does
not accept the unit suffixes NgStyle understands: write { 'top.px': y } as [style.top.px]="y".
<!-- Don't -->
<div [ngStyle]="{ position: 'fixed', 'top.px': position.y, color: disabled ? 'grey' : undefined }"></div>
<!-- Do -->
<div style="position: fixed" [style.top.px]="position.y" [style.color]="disabled ? 'grey' : undefined"></div>
Never bind outerHTML: replacing the element Angular is bound to breaks the next update. Use [innerHTML] to render markup inside an element. The
@angular-eslint/template/no-outerhtml rule enforces this.
Avoid $any() in templates. The @angular-eslint/template/no-any rule enforces this. Read native input values through a template reference and handle library events in
typed component methods. Call signals when checking their value; @angular-eslint/no-uncalled-signals catches checks of the signal function itself.
Use NgOptimizedImage and ngSrc for images whose intrinsic dimensions are known. Supply width and height, or use fill inside a positioned container with a defined
size. Mark an image priority when it is expected to be the largest visible image on initial load. For user supplied images with unknown dimensions that must keep their
intrinsic layout, use native src with appropriate loading and decoding attributes.
Give every image a text alternative that conveys its purpose. Localize descriptions that users hear through a screen reader. Use alt="" for a decorative image or one
whose information is already stated next to it; do not omit the attribute. The @angular-eslint/template/alt-text rule enforces its presence.
Keep keyboard focus in the same order as the page content. Use native controls and arrange the markup in the intended focus order instead of assigning positive tabindex
values. The @angular-eslint/template/no-positive-tabindex rule enforces this.
Associate a form label with its native control: match the label's for to the input's id, or place the input inside the label. When a custom component renders the
input, connect the label to that inner input rather than to the component host. Use a heading or span for text that only names a section or explains a legend. Name a
group of related controls with a fieldset and legend or with aria-labelledby on a group. The
@angular-eslint/template/label-has-associated-control rule checks the label association, not the element choice or group names, in TUM AET UI and selected client
template areas as they are migrated.
Comments & Documentation
- Use JSDoc style comments for functions, interfaces, enums, and classes.
- Provide extensive documentation inline and using JSDoc to make sure other developers can understand the code and the rationale behind the implementation without having to read the code.
Strings
- Use single quotes for strings.
- All strings visible to the user need to be localized (see next section).
Localization
- Make an entry in the corresponding
i18n/{language}/{area}.jsonfiles for all languages Artemis supports (currently English and German). - To display the string in HTML files, use the
jhiTranslatedirective or theartemisTranslatepipe. - To ensure consistency, always choose the directive over the pipe whenever possible.
Do:
<span jhiTranslate="global.title"></span>
<!-- ok, because there is other content in the span as well -->
<span>
{{ 'global.title' | artemisTranslate }}
<fa-icon [icon]="faDelete" />
</span>
Don't do:
<!-- use the directive instead -->
<span>{{ 'global.title' | artemisTranslate }}</span>
<!-- Do not add the translated text between the HTML tags -->
<span jhiTranslate="global.title">Artemis</span>
Buttons and Links
- Be aware that Buttons navigate only in the same tab while Links provide the option to use the context menu or a middle-click to open the page in a new tab. Therefore:
- Buttons are best used to trigger certain functionalities (e.g.
<button (click)='deleteExercise(exercise)'>...</button) - Links are best for navigating on Artemis (e.g.
<a [routerLink]='getLinkForExerciseEditor(exercise)' [queryParams]='getQueryParamsForEditor(exercise)'>...</a>)
Icons with Text
If you use icons next to text (for example for a button or link), make sure that they are separated by a newline. HTML renders one or multiple newlines as a space.
Do this:
<fa-icon [icon]="'times'"></fa-icon> <span>Text</span>
Don't do one of these or any other combination of whitespaces:
<fa-icon [icon]="'times'"></fa-icon><span>Text</span>
<fa-icon [icon]="'times'"></fa-icon><span> Text</span> <fa-icon [icon]="'times'"></fa-icon> <span>Text</span>
<fa-icon [icon]="'times'"></fa-icon>
<span> Text</span>
Ignoring this will lead to inconsistent spacing between icons and text.
Labels
- Use labels to caption inputs like text fields and checkboxes.
- Associated labels help screen readers to read out the text of the label when the input is focused.
- Additionally they allow the label to act as an input itself (e.g. the label also activates the checkbox).
- Make sure to associate them by putting the input inside the label component or by adding the for attribute in the label referencing the id of the input.
Do one of these:
<!-- always prefer this solution -->
<input id="inputId" class="form-check-input" type="checkbox" (click)="foo()" />
<label class="form-check-label" for="inputId" jhiTranslate="artemisApp.labelText"> </label>
<!-- only do this if the first solution does not work -->
<label class="form-check-label">
<input class="form-check-input" type="checkbox" (click)="foo()" />
{{ 'artemisApp.labelText' | artemisTranslate }}
</label>
Code Style & Quality
Code Style
-
Use arrow functions over anonymous function expressions.
-
Always surround arrow function parameters. For example,
x => x + xis wrong but the following are correct:(x) => x + x(x,y) => x + y<T>(x: T, y: T) => x === y
-
Always surround loop and conditional bodies with curly braces. Statements on the same line are allowed to omit braces.
-
Open curly braces always go on the same line as whatever necessitates them.
-
Parenthesized constructs should have no surrounding whitespace. A single space follows commas, colons, and semicolons in those constructs. For example:
for (var i = 0, n = str.length; i < 10; i++) { }if (x < 10) { }function f(x: number, y: string): void { }
-
Use a single declaration per variable statement (i.e. use
var x = 1; var y = 2;overvar x = 1, y = 2;). -
elsegoes on the same line from the closing curly brace. -
Use 4 spaces per indentation.
Cloning objects
Copy objects with the helpers in app/foundation/util/deep-clone.util (thin wrappers over lodash cloneDeep). Never use structuredClone(), object spread ({ ...obj }), or Object.assign on an object — enforced in production client code by localRules/prefer-deep-clone.
| Helper | Replaces | Use for |
|---|---|---|
deepClone(x) | { ...x }, Object.assign({}, x) | A plain copy |
cloneWith(x, { a, b }) | { ...x, a, b }, Object.assign({}, x, { a, b }) | A copy with some fields changed, as one expression |
hydrate(new Course(), dto) | Object.assign(new Course(), dto) | Giving a parsed server DTO a prototype so its methods work |
cloneWith deep-clones its source and applies the overrides by reference, exactly as object spread does — so
cloneWith(option, { selected: signal(false) }) keeps the live signal instead of cloning it into a dead copy. Its
result is typed T & U, so a call may add fields the source type does not declare.
hydrate mutates and returns its target. Reach for it only when populating an object you just created, or when
mutating in place is the deliberate intent; to derive a modified copy of an existing object use cloneWith, which
leaves the original untouched.
Importing lodash cloneDeep directly is blocked by no-restricted-imports: everything goes through these wrappers so
there is a single place to change the implementation.
import { cloneWith, deepClone } from 'app/foundation/util/deep-clone.util';
// Don't - structuredClone() does not preserve prototypes, so `startDate` loses every dayjs
// method and `copy.startDate.format(...)` throws at runtime. Worse, `dayjs.isDayjs(copy.startDate)`
// still returns true (an internal data flag survives the clone), so a guard does not catch it.
const copy = structuredClone(lecture);
// Don't - shallow: `copy.course` is the SAME object as `lecture.course`, so editing the copy
// silently edits the original too.
const copy = { ...lecture };
const copy = Object.assign({}, lecture);
// Do
const copy = deepClone(lecture);
copy.title = 'New title';
// Do - or in a single expression, which keeps signal updates on one line
const renamed = cloneWith(lecture, { title: 'New title' });
Why each alternative fails:
| Approach | Problem |
|---|---|
structuredClone() | Drops prototypes — dayjs dates (and other class instances) lose their methods, while isDayjs() still says true |
{ ...obj } | One level deep only — nested objects/arrays stay shared with the original |
Object.assign({}, obj) | Same shallow-copy problem, and it reads as if the result were independent when it is not |
Object.assign(obj, …) | Also mutates obj in place, which emits no signal notification because a signal compares with Object.is |
This matters most for signals, which only notify when the reference changes (see Zoneless change detection & signal-based state). Replace the object instead of mutating it:
// Do - a new reference, with the nested state safely detached
this.userIdentity.update((current) => {
if (!current) {
return current;
}
const updated = deepClone(current);
updated.imageUrl = url;
return updated;
});
AccountService.setImageUrl and AccountService.setUserLLMSelectionDecision are the canonical examples.
Two things this rule does not cover:
- Arrays. Array spread is the documented way to append immutably:
this.items.update((items) => [...items, newItem]). Reach fordeepCloneonly when you need the array's elements detached as well. - Object rest in destructuring.
const { id, ...rest } = postreads properties rather than copying an object into a new one, and stays allowed.
Writing out a fresh object literal is of course still fine — the rule only fires when you spread or assign an existing object into one. Prefer the literal when you are constructing a payload from individual fields rather than copying.
When you only need a signal to emit
Sometimes the goal is not a copy at all: an object was mutated in place — because the template two-way binds
[(ngModel)]="entity.field", or a websocket event changed one value — and a signal has to emit so the view updates.
Copying the object to change its identity is the wrong tool: it detaches the nested associations, so template
track-by-identity re-creates every row, child inputs re-notify on each change-detection pass (which ends in
NG0103), and any child still holding the old nested objects writes into a detached copy.
Declare the signal with equal: () => false instead. Re-setting the same reference then emits, and no copy happens:
// `equal: () => false` so the signal emits even when `set` receives the same reference: the getter/setter facade
// below is mutated in place, and rebuilding the object would detach what child components hold.
private readonly _course = signal<Course>(undefined!, { equal: () => false });
private commitCourse(): void {
this._course.set(this._course());
}
CourseUpdateComponent, GradingComponent, ExamParticipationComponent and the quizExercise signal in
QuizExerciseValidationDirective all use this. Where the state is not signal-backed, build the replacement object
explicitly, field by field — Post.withSameValues is the example — rather than reaching for a shallow copy.
Two things worth knowing when you do clone:
- Nested values whose tag lodash does not recognise (
File,Blob,Error, DOM nodes, functions and therefore signals) are carried over by reference, so they survive adeepCloneuntouched. That does not hold at the top level:deepClone(someFile)returns{}, not the file. Clone the object that holds it, never the value itself. - A class instance lodash rebuilds from its prototype plus a shallow property copy is not so lucky — an RxJS
Subjectcloned that way looks alive yet is disconnected from the original's subscribers.
equal: () => false also only reaches consumers that read the signal in the declaring component's own template. If
the object is passed to a child input(), the child is not notified: Angular compares a property binding with
Object.is before it reaches the input, so the same reference never arrives, and an input signal does not inherit the
producer's equal. When a child binds the whole object, hand it a new top-level reference built field by field —
Exam.withSameValues and StudentExam.withSameValues do exactly that.
Assembling a view object from parts that must stay reference-identical also reads better as explicit field
assignments than as a copy — see the GradingComponent.gradingScale getter.
Exercise update forms
The text, modeling and file upload exercise forms hold the exercise in an ExerciseFormState
(app/exercise/util/exercise-form-state.ts): a signal declared with equal: () => false and three ways to write to
it — set(exercise), update(mutator) and patch(field, value). Every write notifies, while the object stays the same,
so the children that receive it keep their nested associations. What the form derives from the exercise — the reasons
the footer lists, the sections of the status bar — is therefore a computed(), never a method the template calls or a
value a valueChanges subscription keeps up to date.
A computed only follows writes that go through the state, so a form never writes to the exercise directly:
<!-- Don't - writes onto the object the signal returned; the derived state keeps the old value -->
<input [(ngModel)]="textExercise.maxPoints" name="points" />
<!-- Do - the write goes through the state, which notifies -->
<input [ngModel]="textExercise.maxPoints" (ngModelChange)="exerciseState.patch('maxPoints', $event)" name="points" />
The same goes for writes in code (exerciseState.update(resetForImport), an HTTP callback that sets the course) and for
child components. A child that writes a field the form derives state from announces it with an exerciseChange output
that emits the same object, and the form binds it two-way, either [(exercise)]="textExercise" to a setter that calls
set or (exerciseChange)="exerciseState.set($event)"; TeamConfigFormGroupComponent and
ExerciseTitleChannelNameComponent do that.
Derive validity from the model, not from the state of a form control: NgModel.valid and control.errors are not
signals, so a computed that reads them goes stale. The checks in app/exercise/util/exercise-validation.util.ts take the
exercise and return the reasons, grouped by the section of the form that shows them
(getGeneralSectionInvalidReasons, getModeSectionInvalidReasons, getGradingSectionInvalidReasons). A section is
valid when its reasons are empty, so the status bar and the footer always agree. What a child component knows and the
model does not — whether the title is already used, which plagiarism control is invalid — it exposes as a signal
(isTitleDisallowed, invalidControlNames).
Prettier and ESLint
- We use
prettierto style code automatically andeslintto find additional issues. - You can find the corresponding commands to invoke those tools in
package.json. - Keep Angular lifecycle hooks synchronous and start async work in a separate method (
@angular-eslint/no-async-lifecycle-method). Avoid repeating entries in component metadata arrays (@angular-eslint/no-duplicates-in-metadata-arrays).
Preventing Memory Leaks
It is crucial that you try to prevent memory leaks in both your components and your tests.
What are memory leaks?
A very good explanation that you should definitely read to understand the problem: https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/
In essence:
- JS is a garbage-collected language
- Modern garbage collectors improve on this algorithm in different ways, but the essence is the same: reachable pieces of memory are marked as such and the rest is considered garbage.
- Unwanted references are references to pieces of memory that the developer knows they won't be needing anymore but that for some reason are kept inside the tree of an active root. In the context of JavaScript, unwanted references are variables kept somewhere in the code that will not be used anymore and point to a piece of memory that could otherwise be freed.
What are common reasons for memory leaks?
https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/:
- Accidental global variables
- Forgotten timers or callbacks
- Out of DOM references
- Closures
RXJS subscriptions not being unsubscribed: https://www.twilio.com/blog/prevent-memory-leaks-angular-observable-ngondestroy
Reporting unexpected errors
A bare console call is invisible in production, and ESLint rejects it in application code. Report a state that should never happen, and that the
application recovers from, with captureException from app/core/error-reporting/capture-exception. It sends the report to the server, which
writes it to its log, see Client Error Reports.
Pass an Error where you can, and wrap the cause of a failure with new Error('What failed', { cause: error }), so that the stack of both reaches the log. A failed
request can be the cause, too: a cause that is no Error is described like a reported value, so an HttpErrorResponse appears by its status and its path in the stack, and
its query and the body of its response do not.
Use the context for what helps to find the problem: extra for the identifiers of the affected entities and tags for a short label of the feature. Do not put
whole entities or personal data into it, as every value is shortened to 500 characters and the log is read by operators. Set severity to WARN or INFO for a
problem that needs no action right away; the default is ERROR.
The ClientErrorHandler reports an error that reaches Angular's error handler, which is an unhandled error, a failed request with a status of 500 or higher that nobody
caught, and a promise that is rejected without a handler. So a catch that only rethrows needs no report of its own. An error that somebody catches never reaches it:
if a component catches an unexpected failure, for example a response with a status of 500, and shows a fallback instead of rethrowing it, the failure is not reported
unless the catch calls captureException. The same holds when an intermediate catch rethrows to a caller that handles the error. Do not report an error that the
user is told about and that is expected, such as a rejected input, and do not call captureException in a loop: the client sends the same error only once per minute,
and at most ten reports per minute from one page.
A failed request is reported by its status and its path, so it is safe to pass an HttpErrorResponse to captureException. Do not put names, student identifiers or
other personal data into a message or into extra: the server only removes email addresses, user= assignments, User{...} dumps, the value of a field named like a
login in serialized data, and the participants of repository urls.
UI/UX & Layout
Responsive Layout
Ensure that the layout of your page or component shrinks accordingly and adapts to all display sizes (responsive design).
In a migrated module, use explicit Tailwind width, maximum-width, and logical-spacing utilities.
Avoid the bare .container class while Bootstrap and Tailwind coexist because both frameworks own
that selector.
<div class="mx-auto w-full max-w-7xl px-4 sm:px-6">
<main>…</main>
</div>
Dividers between panels
Every divider between two panels is 6px wide, the --spacing-divider token from tailwind.css. Never size
one in percentages or with its own pixel value.
- A resizable side panel (
jhiResizable) gives its handle the globalpanel-divider-xclass, for example<div class="draggable-left panel-divider-x"></div>. The class sets the width, the small grip and a grab area that reaches 4px into the following panel. - A PrimeNG
p-splittersets[gutterSize]="6"and draws the grip from two 2px bars with a 1px gap, asresizable-panels.component.scssdoes. Its default gutter is 4px.
Styling
Use Tailwind for application layout and TUM AET UI for reusable component styling. Add component SCSS
only when semantic utilities and component contracts cannot express a genuinely component-specific
rule. Keep global styles in src/main/webapp/content/scss limited to application-wide foundations
and legacy integration.
When an application component needs owned class selectors, use BEM:
.my-container {
// container styles
&__content {
// content styles
&--modifier {
// modifier styles
}
}
}
Bootstrap → TUM AET UI/Tailwind quick reference (apply when migrating a module):
| Bootstrap | Target |
|---|---|
btn btn-primary / btn-sm / btn-outline-* / btn-group | tumaet-ui-button or [tumAetUiButton] with severity, size, and variant; tumaet-ui-button-group |
badge / bg-success badge | tumaet-ui-tag with a semantic severity |
alert alert-* | tumaet-ui-message with a semantic severity |
card / collapsible panel | tumaet-ui-card / tumaet-ui-panel; a semantic element with Tailwind utilities for a presentation-only box |
table table-striped table-responsive | tumaet-ui-table, native <table tumAetUiTable>, or tumaet-ui-table-virtual-scroll when virtualization is justified |
form-control / form-group / form-check | [tumAetUiInput], tumaet-ui-select (tumaet-ui-multi-select for several values), tumaet-ui-checkbox, or tumaet-ui-radio-button, composed with Tailwind layout |
| a filter or search input | tumaet-ui-search-field, which brings its own magnifier and clear control; jhi-search-filter wraps it for Artemis |
| tabs / pagination / progress / tooltip / date picker | tumaet-ui-tabs, tumaet-ui-paginator, TUM AET UI progress components, [tumAetUiTooltip], or tumaet-ui-date-picker |
| modal | tumaet-ui-dialog; keep feature state and result handling in the Artemis host |
row + col-md-* | grid grid-cols-1 md:grid-cols-12 + md:col-span-*, or flexbox |
d-flex / d-none | flex / hidden |
justify-content-center / align-items-center / flex-column | justify-center / items-center / flex-col |
text-right / text-left | text-end / text-start |
text-muted | an application style using the semantic --text-body-secondary property |
text-danger / text-success / text-warning / text-info | text-state-danger / text-state-success / text-state-warning / text-state-info |
me-* / ms-* / mb-* / p-* / gap-* | Tailwind logical spacing; convert by target size and write the steps 3 to 5 with ! (Bootstrap mb-3 is 1rem, so mb-4!; see Ambiguous Spacing Utilities) |
Date and time fields use tumaet-ui-date-picker. It is a signal-forms control: bind [(value)] to a dayjs signal or property, or [formField] on a signal form. [min] and [max] grey out the days outside a range in the calendar, but text typed into the field is not limited by them. The picker has no required flag and no message for an external error, so the consumer checks the range or the required state, marks the field with [invalid], and explains the error next to it ([ariaDescribedBy] ties the text to the field). Typed text that is not a date leaves the last valid value in value and is reported through (inputValidityChange), so a consumer must not submit while that is false. Use [dateOnly] for a date, [timeOnly] for a time and [startAt] for the month a calendar opens on while the field is empty. A reactive form that has not moved to signal forms adds DatePickerControlDirective (app/shared-ui/date-picker-control) to keep formControlName; it reports unparseable text as an invalidDate control error and goes away with the last such form. The field never gets narrower than its text needs: 15rem for a date and time, 11.5rem with [dateOnly] and 8rem with [timeOnly]; the [inline] variant has no minimum. A flex or grid parent therefore has to wrap or grow instead of squeezing it, so use grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] (or the matching minimum) rather than a fixed column count. To keep at most two columns, use grid-cols-[repeat(auto-fill,minmax(max(15rem,calc((100%_-_1rem)/2)),1fr))] with gap-4. Do not stack indents in front of a picker on a phone, because a clipping parent hides its buttons. Give the picker its labelName and project a help icon next to it with the tumAetUiDatePickerLabelAddon attribute, so label, icon and time-zone indicator stay on one line; combine [hideLabelName] with an external label only together with [shouldDisplayTimeZoneWarning]="false". For a range, use two pickers rather than a range component.
Colors — use semantic tokens, never primitives. Color follows the three-tier design-token model (primitive → semantic → component): a primitive token names a value (red-500); a semantic token names intent (danger). Always apply a semantic token; never apply a primitive directly — naming the value to express a role drifts and reads inconsistently (the documented design-token anti-pattern). Pick by what you are styling:
| Need | Use | Never |
|---|---|---|
| brand / neutral / surface | an Artemis semantic property, or an app-owned semantic utility when one is defined | text-blue-500, bg-surface-200, bg-[#…], or a framework-owned primitive |
| state on a TUM AET UI component | the component's semantic severity, such as tumaet-ui-message, tumaet-ui-tag, or tumaet-ui-button | a color class that overrides the component |
| state on plain markup (static) | text-state-danger, bg-state-success, border-state-warning, or text-state-info | text-danger (Bootstrap), text-(--danger), or text-(--p-red-500) |
| state that is dynamic / conditional | a semantic component input, or [style.color]="cond ? 'var(--danger)' : undefined" for irreducible inline UI | [class.text-danger], [style.color]="'var(--p-red-500)'", or a raw color value |
The Artemis state tokens are --danger, --success, --warning, and --info. They are generated
for light and dark mode and feed the TUM AET UI host-token adapter. Legacy PrimeNG also receives these
roles during migration, but it does not own them. Semantic markup and TUM AET UI therefore share one
source of truth without component-level dark: color branches.
The state-* names are Artemis's semantic namespace and avoid collisions with Bootstrap's
unlayered color utilities during migration. @theme inline maps them directly to Artemis theme
properties in src/main/webapp/tailwind.css.
Pick the tool by meaning, not by syntax. Render a labelled status with tumaet-ui-tag or
tumaet-ui-message and its semantic severity; the component owns the theme-aware visual treatment.
Use text-state-* only for irreducible inline color with no suitable component host, such as a
translated validation hint or a state-colored status glyph. Do not add a component wrapper solely
to avoid an otherwise appropriate semantic utility.
Accessibility: color is never the only signal (WCAG 1.4.1) — pair --danger / --warning with an icon or text cue.
localRules/no-raw-tailwind-color-palette rejects raw palette and hexadecimal classes, primitive
PrimeUI color properties used for meaning, and superseded arbitrary state forms. The
no-primeng-component-classes rule prevents hand-painted PrimeNG root classes in remaining legacy
uses. Bootstrap component and color classes are rejected by no-bootstrap-classes in fully
migrated modules; the matching Stylelint override rejects hexadecimal and --bs-* values there.
Keep the ESLint, Stylelint, and Tailwind source lists synchronized as described below.
<div class="flex items-center justify-between gap-2">
<h5 class="mb-0" jhiTranslate="..."></h5>
<tumaet-ui-button size="small" (clicked)="save()"> {{ 'entity.action.save' | artemisTranslate }} </tumaet-ui-button>
</div>
Coexistence with Bootstrap (transitional). Bootstrap is still loaded globally for not-yet-migrated modules. To avoid global breakage during coexistence, tailwind.css deliberately does not import Tailwind's Preflight (Bootstrap's Reboot stays the authoritative reset) and uses source(none) with an explicit @source allowlist (one line per migrated module) so Tailwind only emits utilities for migrated code. A migrated module must be fully converted — never use a Tailwind utility to override a Bootstrap-styled element, because Tailwind's layer loses the cascade to unlayered Bootstrap. Once a module is Bootstrap-free, lock it in three places the migration-source-coverage test (config/eslint/rules/) keeps consistent: the no-bootstrap-classes ESLint glob (eslint.config.mjs) and the stylelint hex/--bs- override (config/stylelint/stylelint.config.json) must name the same modules, and every locked path must appear in the Tailwind @source allowlist (tailwind.css) — which may be a superset, since a partially-migrated module is scanned for its utilities before it is fully lockable. A missing @source entry fails silently (the module's utilities simply never generate), so the test turns any drift into a red build.
Migrate a section — the runbook. Migrating a section off Bootstrap, in order:
- Closure first, not directory. Map what actually renders: the section's component tree plus the global shell (footer, navbar, alert overlay) and any runtime dialogs (
jhiDeleteButton → delete-dialog → confirm-entity-name). For each shared/leaf component in that closure — if already locked, reuse it; if small and section-local, migrate it first as its own locked unit; if app-wide and not yet migrated, leave it Bootstrap and do not put a Tailwind utility on its host (a half-migrated shared component loses the cascade everywhere it renders). - Enumerate + triage. Grep the closure's
.htmlfor Bootstrap classes and its.scss/.tsfor hex /var(--bs-*)/ class-strings. Discount false positives (shared spacingmb-*/me-*/gap-*are allowed, but see Ambiguous Spacing Utilities below for the values 3 to 5; manyrow/tablehits arep-tablestyleClassor custom classes). - Convert templates fully (never half). Use the TUM AET UI and Tailwind quick-reference table above,
and convert spacing by target size rather than copying the class index; spell the steps 3 to 5 as
Ambiguous Spacing Utilities describes. Import package symbols from
@tumaet/ui-angular, never through a deep path. - Replace legacy behavior with the target primitive. Use
tumaet-ui-dialogfor modal presentation,[tumAetUiTooltip]for tooltips, and the corresponding TUM AET UI control where it exists. Keep application state and results in the Artemis component. If TUM AET UI lacks a capability, either add the reusable contract or document a contained PrimeNG fallback. - Convert colors in
.html,.scss, and.ts. Template lint cannot see every color in TypeScript class maps or SCSS. Replace Bootstrap classes and raw values with TUM AET UI inputs, Tailwind semantic utilities, or Artemis semantic properties. - Strip dead SCSS — prefer deleting component SCSS in favour of utilities; remove
::ng-deepbandaids. - Drive Bootstrap hits to zero across the closure — a module must be 100% converted before it renders correctly. Run
pnpm migrate:check <path>to list what Bootstrap remains in a path and whether it is ready to lock;pnpm migrate:statusshows the remaining-Bootstrap burndown by section to pick the next one. - Lock it — add the path to the three lists the
migration-source-coveragetest keeps consistent.no-raw-tailwind-color-paletteandno-primeng-component-classesare app-wide and need no per-section wiring. - Prove it —
pnpm run test:rules && pnpm run lint && pnpm run stylelint && pnpm run prettier:check. - Restart the dev server + verify light AND dark.
@sourcechanges need a fresh Tailwind scan (ng servedoes not reliably HMRtailwind.css); also check a not-yet-migrated page that shares the same closure components, for spillover.
| Symptom | Cause | Fix |
|---|---|---|
| Styles absent in the build | path missing from @source | add it (step 8) |
| Spacing shifted ~4× | copied the class index instead of converting by rem | step 3 |
| Spacing changes when Bootstrap is removed | a bare mb-3 to mb-5 renders Bootstrap's value | write the important spelling (step 3) |
| A legacy control renders grey or unstyled | a p-* root class hand-painted on a bare element | use TUM AET UI or the actual fallback component |
| A migrated element still shows Bootstrap styling | a half-migrated ancestor/shared component wins the cascade | convert the whole closure (step 1) |
Exit criteria. Bootstrap can be removed after every rendering closure is migrated and locked. That removal enables Tailwind Preflight, removes the source allowlist and Bootstrap dependency, and drops Bootstrap-specific token aliases. PrimeNG can be removed after its remaining capability gaps have TUM AET UI replacements and all imports, theme setup, and PrimeUI-specific utilities are gone. The TUM AET UI host adapter keeps these migrations behind one boundary, so package component APIs and semantic tokens do not need to change as either legacy framework leaves.
Ambiguous Spacing Utilities
Bootstrap and Tailwind define the same class names for margin, padding and gap (m, mt, mb, ms, me, mx,
my, p, pt, pb, ps, pe, px, py and gap, each with 0 to 5), but they scale them differently. The two
agree for 0 to 2 and differ for 3 to 5:
| Class suffix | Bootstrap (wins while loaded) | Tailwind |
|---|---|---|
-3 | 1rem (16px) | 0.75rem (12px) |
-4 | 1.5rem (24px) | 1rem (16px) |
-5 | 3rem (48px) | 1.25rem (20px) |
Bootstrap's utilities are unlayered and !important, so while Bootstrap is loaded it wins: a bare mb-3 renders
16px, and renders 12px once Bootstrap is removed. The author of a template cannot tell which of the two they get, and
the element shifts silently when Bootstrap leaves. localRules/no-ambiguous-spacing-utility
(config/eslint/rules/no-ambiguous-spacing-utility.mjs) reports every such bare token in a static class,
[class.<token>], [class], [ngClass] and the styleClass inputs (including component-specific ones such as
contentStyleClass), and offers both fixes as ESLint suggestions:
- To keep the value Bootstrap renders, write the important Tailwind step that has the Bootstrap value:
mb-3becomesmb-4!,mb-4becomesmb-6!,mb-5becomesmb-12!. - To take the Tailwind value, write it with the important modifier:
mb-3!,mb-4!,mb-5!.
The ! is needed in both spellings. mb-4! is a different class from Bootstrap's mb-4, so Bootstrap's rule does not
apply to it. And a bare mb-6, although Bootstrap has no such class, is a normal declaration in a Tailwind layer,
which loses to Bootstrap's Reboot and to component SCSS that set the same property: a <p class="mt-6"> renders no
top margin, because Reboot sets p { margin-top: 0 }, while Bootstrap's mt-4 is important and wins.
The rule does not report what is already unambiguous: a variant (md:mb-3), an important token (mb-3!), an
arbitrary value (mb-[3px]), a negative one (-mb-3), the steps 0 to 2, steps above 5, and ml, mr, pl and
pr, which Bootstrap 5 does not define. Neither does it report utilities that merely look similar, such as gap-x-3
or space-x-3. It judges the class names that are in the template text: a name that an expression computes
([class]="spacing"), a literal inside an interpolation of a class list, a plain ngClass="..." attribute, the case
body of an ICU expression and the inline template: of a component are not looked at.
It is enabled as an error for the Tailwind-laid-out templates of the student exam mode and the pages it shows: the
templates under app/exam/overview, app/exam/shared, app/assessment/overview,
app/assessment/manage/complaints-for-tutor, app/assessment/manage/complaint-response, app/exercise/feedback,
app/text/overview/text-editor and app/fileupload/overview/file-upload-submission. Student-reachable folders that are
still laid out with Bootstrap, such as the code editor and the modeling submission, join the list with their migration. To adopt it
for another folder,
add <folder>/**/*.html to the block that enables it in eslint.config.mjs, and fix the findings with the first
suggestion, which changes nothing on screen. The folder must be listed in the @source allowlist of tailwind.css,
because the classes the rule asks for exist only if Tailwind scans the template; the migration-source-coverage test
fails otherwise. Update the specs that assert the old class names and run pnpm run test:rules and pnpm run lint.
Chart Instantiation
Charts come from the TUM AET UI kit: tumaet-ui-bar-chart, tumaet-ui-line-chart and tumaet-ui-doughnut-chart in
@tumaet/ui-angular. They render inline SVG, so every axis tick, category label and data label is a real
<text> node that can be selected, copied and searched, and each chart renders a visually hidden data table
beside it for assistive technology.
Do not build the SVG yourself. app/shared-ui/chart/tum-aet-ui-chart-adapters.ts maps Artemis' series models onto
the shape the components consume, so a chart stays a matter of data plus a small config object:
| Helper | Purpose |
|---|---|
singleSeriesChart | One value per category: the bars of a simple bar chart, or the slices of a doughnut |
stackedBarChart, normalizedStackedBarChart | ChartMultiSeriesEntry[] into stacked bars, optionally normalised so each bar fills |
multiSeriesLineChart | ChartMultiSeriesEntry[] into one line per entry |
referenceLineSeries | A flat dashed marker, e.g. an average, excluded from legend and select events |
A typical component looks like this — a signal for the data, a computed config, and an explicit height on the
chart's container:
<h4 id="score-distribution-heading" jhiTranslate="statistics.score_distribution"></h4>
<div style="height: 250px">
<tumaet-ui-bar-chart
[labels]="chartData().labels"
[series]="chartData().series"
[config]="chartConfig()"
interactive
(dataSelect)="onSelect($event)"
[ariaLabel]="'statistics.score_distribution' | artemisTranslate"
/>
</div>
readonly chartData = computed(() => singleSeriesChart(this.chartEntries(), [GraphColors.DARK_BLUE]));
readonly chartConfig = computed<TumAetUiBarChartConfig>(() => ({
percentScale: true,
xAxis: { label: this.xAxisLabel() },
yAxis: { max: this.yScaleMax() },
tooltip: { label: (item) => `${this.translateService.instant('statistics.amountOfStudents')}: ${item.value}` },
dataLabels: { formatter: (value) => `${value}%` },
}));
Here are the rules to follow when adding a chart:
-
Give the chart's container an explicit height. A chart fills its container in both dimensions, so a parent without a definite height collapses it to nothing. If the parent is a flex item,
flex: 1; min-height: 0works too; a plain block needsstyle="height: …"or a[style.height.px]binding. For a horizontal bar chart, derive the height from the number of bars so each one stays readable:readonly chartHeight = computed(() => AXIS_AND_PADDING_HEIGHT_PX + this.chartEntries().length * BAR_HEIGHT_PX); -
Every chart needs an accessible name. The chart renders an
<svg role="img">, and the same name becomes the caption of its data table. Without one, a screen reader announces an unlabelled image over an uncaptioned table. Supply one of:<!-- Preferred: reuse the translation key of the chart's own visible heading --><tumaet-ui-line-chart [labels]="…" [series]="…" [ariaLabel]="'artemisApp.courseStatistics.activeStudents' | artemisTranslate" /><!-- Or name it from the visible heading, when that heading has a stable unique id --><tumaet-ui-bar-chart [labels]="…" [series]="…" [ariaLabelledBy]="'active-students-heading'" /><!-- Or, ONLY if the chart is decorative because every figure it shows is also rendered as text next to it --><tumaet-ui-doughnut-chart [labels]="…" [series]="…" aria-hidden="true" />The
localRules/require-chart-accessible-nameESLint rule enforces this for external templates and inlinetemplate:strings alike. When one page renders several instances of the same chart component, pass the name in per instance (seeParticipantScoresDistributionComponent'sariaLabelinput) so they can be told apart.Note that
aria-labelis a name, not a description: it says what the chart is, not what it shows. The figures themselves are exposed through the data table the chart renders for you. -
Pass colors as
var(--token)references, never resolved hex values. SVG consumes CSS custom properties directly, so hand the components theGraphColorstokens as they are. A theme switch then repaints the chart without re-rendering it, and there is no color resolution step to keep in sync. -
Keep axis labels, ticks and tooltips translation-sensitive. The config is built in TypeScript, so it does not re-render on a language change by itself. Either build the config in a
computedthat reads a signal fed byonLangChange, or subscribe and recompute:private readonly languageChange = toSignal(this.translateService.onLangChange, { initialValue: undefined });protected readonly chartData = computed(() => {this.languageChange();return this.buildTranslatedChartData();}); -
Do not override chart internals with
::ng-deep. Use the documented component inputs, the adapters, or the semantic tokens. If a chart cannot express what you need, extend the components in the kit rather than coupling Artemis to their private markup.
Some parts of these guidelines are adapted from https://github.com/microsoft/TypeScript-wiki/blob/main/Coding-guidelines.md
Defining Routes and Breadcrumbs
The ideal schema for routes is that every variable in a path is preceded by a unique path segment: /entityA/:entityIDA/entityB/:entityIDB
For example, /courses/:courseId/:exerciseId is not a good path and should be written as /courses/:courseId/exercises/:exerciseId.
Doubling textual segments like /lectures/statistics/:lectureId should be avoided and instead formulated as /lectures/:lectureId/statistics.
When creating a completely new route you will have to register the new paths in navbar.ts. A static/textual url segment gets a translation string assigned in the mapping table. Due to our code-style guidelines any - in the segment has to be replaced by a _. If your path includes a variable, you will have to add the preceding path segment to the switch statement inside the addBreadcrumbForNumberSegment method.
const mapping = {
courses: 'artemisApp.course.home.title',
lectures: 'artemisApp.lecture.home.title',
// put your new directly translated url segments here
// the index is the path segment in which '-' have to be replaced by '_'
// the value is the translation string
your_case: 'artemisApp.cases.title',
};
addBreadcrumbForNumberSegment(currentPath: string, segment: string): void {
switch (this.lastRouteUrlSegment) {
case 'course-management':
// handles :courseId
break;
case 'lectures':
// handles :lectureId
break;
case 'your-case':
// add a case here for your :variable which is preceded in the path by 'your-case'
break;
}
}
Redirecting from guards and resolvers
A route guard or resolver runs while the router is in the middle of a navigation. To send the user somewhere else, it hands the router a redirect and never navigates itself:
- A guard — a
CanActivateFn,CanActivateChildFn,CanMatchFnorCanDeactivateFn, or a class implementingCanActivate,CanActivateChild,CanMatchorCanDeactivate— returns or emitsrouter.createUrlTree(...). When the redirect needs navigation options such asreplaceUrl,skipLocationChangeorstate, it returnsnew RedirectCommand(router.createUrlTree(...), options)instead. - A resolver — a
ResolveFnor a class implementingResolve— returns or emits aRedirectCommand. AUrlTreereturned from a resolver becomes the resolved route data; it does not redirect. - Inside a resolver's observable or promise chain, such as an RxJS
mapcallback or a helper the resolver calls, it can throw theRedirectCommand, which extendsError. The router handles it as a redirect. Guards must return or emit their redirect, including inside callbacks. Throwing aRedirectCommandbypasses guard-result ordering and can redirect before an earlier guard rejects the route.
The router then cancels the running navigation with a NavigationCancel event whose code is
NavigationCancellationCode.Redirect, not with a NavigationError, and navigates to the target. The redirect keeps
the replaceUrl and skipLocationChange of the navigation it replaces, unless the RedirectCommand sets them. It
does not keep the state; pass it in the RedirectCommand options when the target needs it. The first navigation
after the page loads and every back or forward navigation run with replaceUrl, so a redirect from a bookmark, a
reload or the Back button replaces the guarded URL in the browser history instead of adding an entry after it. A
caller awaiting router.navigate(...) for the original navigation receives the redirect's result.
Calling router.navigate(...) or router.navigateByUrl(...) from a guard or resolver cancels the running navigation
on the spot, with the code NavigationCancellationCode.SupersededByNewNavigation, and starts a new one. That has
three costs:
- The options of the original navigation are lost. The new navigation has neither its
replaceUrlnor itsskipLocationChange. For a bookmark, a reload or the Back button, the target is therefore added after the guarded URL, so Back leads to the guarded URL, which redirects forward again and traps the user. - The caller is told the navigation failed. A caller awaiting
router.navigate(...)for the original navigation receivesfalse, although the user ends up on the target. - The navigation happens even when another guard rejects the route. Once one guard rejects, the router stops waiting for the others, but an asynchronous guard keeps running and still navigates when it finishes. A synchronous guard navigates before the guards listed ahead of it have decided.
A return false after the call, or a resolver completing with EMPTY, changes none of this: the navigation is
already cancelled when the guard or resolver returns, so those values only satisfy the return type.
// Don't - navigate from the guard; the returned false has no effect any more
canActivate(): boolean {
if (!this.profileService.isModuleFeatureActive(MODULE_FEATURE_LECTURE)) {
void this.router.navigate(['/']);
return false;
}
return true;
}
// Do - return the redirect
canActivate(): true | UrlTree {
if (!this.profileService.isModuleFeatureActive(MODULE_FEATURE_LECTURE)) {
return this.router.createUrlTree(['/']);
}
return true;
}
In a resolver that decides inside its RxJS pipeline, throwing the redirect keeps the resolver's type
Observable<Course>, because a redirect is never emitted as data:
// Don't - navigate from the resolver; completing with EMPTY has no effect any more
switchMap((course: Course) => {
if (!course.isAtLeastTutor) {
this.alertService.error('artemisApp.pages.tutorialGroupsManagement.notAuthorized');
void this.router.navigate(['/courses']);
return EMPTY;
}
return of(course);
}),
// Do - throw the redirect; the alert is shown before the router redirects
map((course: Course) => {
if (!course.isAtLeastTutor) {
this.alertService.error('artemisApp.pages.tutorialGroupsManagement.notAuthorized');
throw new RedirectCommand(this.router.createUrlTree(['/courses']));
}
return course;
}),
A thrown RedirectCommand travels through the rest of the pipeline like any other error, so a catchError after it
must pass on what it does not handle (return throwError(() => error)) rather than swallow it.
Several guards on one route. The guards in one canActivate array run together, and the router applies the first
emitted result that is not true, in array order, waiting for the guards ahead of it. A returned or emitted redirect
therefore takes effect only when every guard before it returns true. A thrown redirect bypasses this ordering.
On a route guarded by [UserRouteAccessService, LocalCIGuard], a user who lacks the route's authority is rejected and
stays where they were, and only a user who passes the authority check is redirected. List the guard whose outcome takes precedence first.
To test a guard or resolver, inject the real Router with TestBed.inject(Router) and assert the redirect itself,
not a spy on navigate:
const result = guard.canActivate();
expect(result).toBeInstanceOf(UrlTree);
expect(router.serializeUrl(result as UrlTree)).toBe('/');
For a thrown redirect, subscribe with an error callback and check that it receives a RedirectCommand whose
redirectTo serializes to the expected URL. To test how guards combine, or what ends up in the browser history,
configure the routes with provideRouter(...) and provideLocationMocks(), navigate, and assert router.url and the
urlChanges of the SpyLocation; localci-guard.spec.ts shows both.
The localRules/no-navigation-in-guard-or-resolver ESLint rule enforces this under src/main/webapp at error level.
It checks:
- functional guards and resolvers declared with one of the types above, whether as a variable annotation, an
asorsatisfiesassertion, or the return type of a function that creates them; - the
canActivate,canActivateChild,canMatch,canDeactivateandresolvemethods of classes implementing the matching interface; - guards and resolvers declared in a route, inline or by the name of a function or class in the same file.
The check follows the guard or resolver into nested callbacks, into the methods of its own class that it reaches
through this, and into functions of the same file that it references. It reports calls on a Router obtained with
inject(Router) or through constructor injection. Navigation from components is unaffected, and so is a component's
own canDeactivate() method, which is not one of Angular's guard interfaces. A navigation sequence that a single
redirect cannot express needs a line-level eslint-disable-next-line with the reason.
The check reads one file at a time and does not resolve types, so it does not report:
- navigation inside an injected service that the guard or resolver calls;
- a
Routerheld in a field of a base class, copied into a local (const router = this.router), or obtained throughinjector.get(Router); - guard types and the
Routerimported through a namespace import (import * as router from '@angular/router'); - static methods called through the class name;
- route objects that have none of
path,matcher,component,loadComponent,children,loadChildrenandredirectTo.
Moving the call into a service therefore silences the rule without solving the problem. Return the redirect from the guard or resolver itself.
Setting inputs of a component created in code
ComponentRef.setInput(name, value) identifies the input by a plain string and accepts any value. A misspelled or removed
input still compiles. At runtime Angular only logs NG0303 with console.error, it does not throw, and in production it logs
nothing, so the component silently keeps its default and the bug shows up far from its cause.
Production code therefore does not call setInput. Choose in this order:
-
Declare the component in a template. When the set of components is closed and known, put them in a
@switch. The template compiler then checks every input name and value type.SidebarCardComponentrenders the small, medium and large sidebar card this way, and passesactiveItemIdonly to the medium card, which is the only one that declares it. -
Use
setInputswhen the component has to be created in code, for example a widget inside an editor, a component rendered into markdown HTML or the content of an overlay. Import it fromapp/foundation/util/set-inputs.util:const widgetRef = this.viewContainerRef.createComponent(ReviewCommentThreadWidgetComponent);setInputs(widgetRef, { thread, showLocationWarning, showFeedbackAction });The input names and the value types are checked against the component class at compile time. A misspelled name, a value of the wrong type and a member that is not an input are all compile errors. Every input that is passed needs a value of its declared type, so
undefinedis only accepted by an input whose type includes it. Pass a plain object, not a class instance, and a ref to one component type: a union of components is rejected, because its members can declare the same input differently.
Inputs are named by the class member, and an input that is declared with an alias is set under its alias. When the
component is chosen from data, narrow the data first and tie the two together with one type parameter, so that a component
is only accepted with the kind of data it declares. ExerciseDetailDirective does this with a switch on the detail type
and a render<D extends ShownDetail>(component: Type<{ readonly detail: InputSignal<D> }>, detail: D) method. A lookup table from the type to
the component cannot express that link, and InputSignal is invariant, so a map of components to one common input type does
not compile either.
inputBinding(...), the inputs of ngComponentOutlet and the inputValues of the PrimeNG DialogService take the name
as a string as well and are not type checked, so they are no substitute. The rule does not cover them, because they are not
calls to setInput.
@tumaet/ui-angular owns and exports the shared implementation in packages/tum-aet-ui/src/lib/internal/set-inputs.ts.
The application utility re-exports it, so existing callers keep their import path without a second implementation.
localRules/no-component-ref-set-input (config/eslint/rules/no-component-ref-set-input.mjs) bans any use of the setInput
member in production code under src/main/webapp and packages/tum-aet-ui at error level, so aliasing, destructuring and
call, apply or bind do not get around it. Specs are exempt, because they set inputs on fixtures. The package helper is the
only place that disables the rule.
Advanced Topics
Checking access before a request
AccountService is the one place in the client that knows who the user is and what the server lets them do. It holds
the authorities and course roles of the signed-in user, and the rules the server applies on top of them, such as the
passkey sign-in that administrator features can require. Ask it before sending a request that needs more than a
signed-in user, and do not rebuild its decisions in a component or another service.
A request the server refuses answers with 403, and the global error handling then shows the alert You are not authorized to access this page. on whatever page is open. For a request the user started, that is the right answer. For one the client sends on its own, on sign-in, when a page opens, or for a navbar element, the user sees an error about something they never asked for.
- Check before a request that runs without a user action. Call the
AccountServicemethod that answers what the server endpoint requires, and skip the request when the answer is no. The variant job tray, for example, callshasEditorAccess()before it loadsGET /api/hyperion/variant-jobs, which only lists the jobs of the caller. - Use the high-level methods, not the raw authorities. An authority list such as
IS_AT_LEAST_EDITORincludes the administrator authorities, and the administrator authority alone is not what the server checks: on a server that requires a passkey for administrator features, an administrator who signed in with a password has no editor access until they sign in with an approved passkey.hasEditorAccess()knows this, whilehasAnyAuthorityDirect(IS_AT_LEAST_EDITOR)does not. Do not combine authorities, module features and passkey state yourself either. - Add a method when none fits. If no method answers what an endpoint requires, add one to
AccountServiceand toMockAccountServiceinsrc/test/javascript/spec/helpers/mocks/service/mock-account.service.ts. The rule then lives in one place and its tests belong toaccount.service.spec.ts, while the specs of its callers only stub the answer.
The check in the client only avoids requests that are bound to fail. The server still enforces access on every endpoint, so a missing client check never grants access, and a client check never replaces the one on the server.
WebSocket Subscriptions
The client must not subscribe to more than 20 WebSocket topics simultaneously, regardless of the amount of exercises, lectures, courses, etc. there are for one particular user.
Best Practices:
- Dynamic Subscription Handling: Subscribe to topics on an as-needed basis. Unsubscribe from topics that are no longer needed to keep the number of active subscriptions within the recommended limit.
- Efficient Topic Aggregation: Use topic aggregation techniques to consolidate related data streams into a single subscription wherever possible. Consequently, don't create a new topic if an existing one can be reused.
- Small Messages: Send small messages and use DTOs. See the server guidelines for more information and examples on DTO usage.
Strict Template Check
To prevent errors for strict template rule in TypeScript, Artemis uses the following approaches:
Use ArtemisTranslatePipe instead of TranslatePipe
Do not use placeholder="{{ 'global.form.newpassword.placeholder' | translate }}"
Use placeholder="{{ 'global.form.newpassword.placeholder' | artemisTranslate }}"
Use ArtemisTimeAgoPipe instead of TimeAgoPipe
Do not use <span [tumAetUiTooltip]="submittedDate | artemisDate">{{ submittedDate | amTimeAgo }}</span>
Use <span [tumAetUiTooltip]="submittedDate | artemisDate">{{ submittedDate | artemisTimeAgo }}</span>