Skip to main content

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(), and output() instead of @Output():

    // Don't - legacy decorators are forbidden
    @Input() myInput: string;
    @Output() myOutput = new EventEmitter<string>();

    // Do - use signal-based APIs
    myInput = input<string>();
    myRequiredInput = input.required<string>();
    myOutput = output<string>();
  • Use viewChild() / viewChild.required() instead of @ViewChild(), and viewChildren() instead of @ViewChildren():

    // Don't - legacy decorators are forbidden
    @ViewChild('myRef') myRef?: MyComponent;
    @ViewChildren(MyComponent) myRefs?: QueryList<MyComponent>;

    // Do - use signal-based query APIs
    myRef = viewChild<MyComponent>('myRef');
    myRequiredRef = viewChild.required<MyComponent>('myRef');
    myRefs = viewChildren<MyComponent>(MyComponent);
  • Use signal(), computed(), and effect() for component state instead of plain class fields:

    // Don't - plain fields don't integrate with Angular's change detection
    count = 0;
    doubled = this.count * 2;

    // Do - signals enable fine-grained reactivity
    count = signal(0);
    doubled = computed(() => this.count() * 2);
  • A computed(), linkedSignal(), effect() or afterRenderEffect() must read at least one signal, otherwise it runs once and never again. Either a signal read is missing (count instead of count()), or the value is a constant and belongs in a plain readonly field. The @angular-eslint/reactive-context-must-read-signal rule reports this, and @angular-eslint/computed-must-return reports a computed() 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 read undefined from a service declared further down, and TypeScript does not catch that indirection. The @angular-eslint/inject-at-top rule enforces this.

  • Declare an application-wide service with @Service() instead of @Injectable({ providedIn: 'root' }). It is the same provider, it is what ng generate service emits, and it rejects constructor injection at compile time. The autofixable @angular-eslint/prefer-service-decorator rule enforces this, and @Injectable stays 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 @Injectable on 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 OnPush change 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:

  1. Use semantic native HTML when the platform already supplies the required behavior.
  2. Use an existing TUM AET UI component for a reusable control or visual pattern.
  3. 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.
  4. 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.
  5. 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 navigation effect, 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 stable computed() key (so a replaced object with an unchanged id does not re-run the effect) and wrap the navigate(...) call in untracked(). The localRules/no-navigation-in-effect ESLint rule enforces this — navigation in an effect that is not wrapped in untracked() 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 in untracked(). This case cannot be linted reliably (.set() / .update() are indistinguishable from Map/Set mutations 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.

MemberSignal API
Local mutable state read by the templatesignal() + .set() / .update()
Value derived from other signals/inputscomputed()
Component inputinput() / input.required()
Two-way bound inputmodel()
Derived value that is also locally settablelinkedSignal()
@ViewChild / @ViewChildren / @ContentChildviewChild() / 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 shapeIdiomatic signal replacement
Recompute a value derived from inputs/statecomputed() — lazy, memoised, cannot go stale
Derived value that can also be set locallylinkedSignal()
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 initialiseexceptional 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 interface over type whenever possible.

    // Dont do
    type AngularLink = {
    text: string;
    routerLink: (string | number)[];
    };

    // Do
    interface 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:

  1. Give it a real default when one exists (preferred — it also removes latent undefined access):

    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.

  2. Make it optional (?) when the field may legitimately be absent and its reads already tolerate undefined:

    private sub?: Subscription; // assigned in ngOnInit(), torn down with this.sub?.unsubscribe()
  3. 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, a viewChild, 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 init

    A 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}.json files for all languages Artemis supports (currently English and German).
  • To display the string in HTML files, use the jhiTranslate directive or the artemisTranslate pipe.
  • 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>
  • 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 + x is wrong but the following are correct:

    1. (x) => x + x
    2. (x,y) => x + y
    3. <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:

    1. for (var i = 0, n = str.length; i < 10; i++) { }
    2. if (x < 10) { }
    3. function f(x: number, y: string): void { }
  • Use a single declaration per variable statement (i.e. use var x = 1; var y = 2; over var x = 1, y = 2;).

  • else goes 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.

HelperReplacesUse 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:

ApproachProblem
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 for deepClone only when you need the array's elements detached as well.
  • Object rest in destructuring. const { id, ...rest } = post reads 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 a deepClone untouched. 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 Subject cloned 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 prettier to style code automatically and eslint to 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 global panel-divider-x class, 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-splitter sets [gutterSize]="6" and draws the grip from two 2px bars with a 1px gap, as resizable-panels.component.scss does. 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):

BootstrapTarget
btn btn-primary / btn-sm / btn-outline-* / btn-grouptumaet-ui-button or [tumAetUiButton] with severity, size, and variant; tumaet-ui-button-group
badge / bg-success badgetumaet-ui-tag with a semantic severity
alert alert-*tumaet-ui-message with a semantic severity
card / collapsible paneltumaet-ui-card / tumaet-ui-panel; a semantic element with Tailwind utilities for a presentation-only box
table table-striped table-responsivetumaet-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 inputtumaet-ui-search-field, which brings its own magnifier and clear control; jhi-search-filter wraps it for Artemis
tabs / pagination / progress / tooltip / date pickertumaet-ui-tabs, tumaet-ui-paginator, TUM AET UI progress components, [tumAetUiTooltip], or tumaet-ui-date-picker
modaltumaet-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-noneflex / hidden
justify-content-center / align-items-center / flex-columnjustify-center / items-center / flex-col
text-right / text-lefttext-end / text-start
text-mutedan application style using the semantic --text-body-secondary property
text-danger / text-success / text-warning / text-infotext-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:

NeedUseNever
brand / neutral / surfacean Artemis semantic property, or an app-owned semantic utility when one is definedtext-blue-500, bg-surface-200, bg-[#…], or a framework-owned primitive
state on a TUM AET UI componentthe component's semantic severity, such as tumaet-ui-message, tumaet-ui-tag, or tumaet-ui-buttona color class that overrides the component
state on plain markup (static)text-state-danger, bg-state-success, border-state-warning, or text-state-infotext-danger (Bootstrap), text-(--danger), or text-(--p-red-500)
state that is dynamic / conditionala 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:

  1. 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).
  2. Enumerate + triage. Grep the closure's .html for Bootstrap classes and its .scss/.ts for hex / var(--bs-*) / class-strings. Discount false positives (shared spacing mb-*/me-*/gap-* are allowed, but see Ambiguous Spacing Utilities below for the values 3 to 5; many row/table hits are p-table styleClass or custom classes).
  3. 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.
  4. Replace legacy behavior with the target primitive. Use tumaet-ui-dialog for 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.
  5. 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.
  6. Strip dead SCSS — prefer deleting component SCSS in favour of utilities; remove ::ng-deep bandaids.
  7. 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:status shows the remaining-Bootstrap burndown by section to pick the next one.
  8. Lock it — add the path to the three lists the migration-source-coverage test keeps consistent. no-raw-tailwind-color-palette and no-primeng-component-classes are app-wide and need no per-section wiring.
  9. Prove it — pnpm run test:rules && pnpm run lint && pnpm run stylelint && pnpm run prettier:check.
  10. Restart the dev server + verify light AND dark. @source changes need a fresh Tailwind scan (ng serve does not reliably HMR tailwind.css); also check a not-yet-migrated page that shares the same closure components, for spillover.
SymptomCauseFix
Styles absent in the buildpath missing from @sourceadd it (step 8)
Spacing shifted ~4×copied the class index instead of converting by remstep 3
Spacing changes when Bootstrap is removeda bare mb-3 to mb-5 renders Bootstrap's valuewrite the important spelling (step 3)
A legacy control renders grey or unstyleda p-* root class hand-painted on a bare elementuse TUM AET UI or the actual fallback component
A migrated element still shows Bootstrap stylinga half-migrated ancestor/shared component wins the cascadeconvert 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 suffixBootstrap (wins while loaded)Tailwind
-31rem (16px)0.75rem (12px)
-41.5rem (24px)1rem (16px)
-53rem (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-3 becomes mb-4!, mb-4 becomes mb-6!, mb-5 becomes mb-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:

HelperPurpose
singleSeriesChartOne value per category: the bars of a simple bar chart, or the slices of a doughnut
stackedBarChart, normalizedStackedBarChartChartMultiSeriesEntry[] into stacked bars, optionally normalised so each bar fills
multiSeriesLineChartChartMultiSeriesEntry[] into one line per entry
referenceLineSeriesA 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:

  1. 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: 0 works too; a plain block needs style="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);
  2. 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-name ESLint rule enforces this for external templates and inline template: strings alike. When one page renders several instances of the same chart component, pass the name in per instance (see ParticipantScoresDistributionComponent's ariaLabel input) so they can be told apart.

    Note that aria-label is 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.

  3. Pass colors as var(--token) references, never resolved hex values. SVG consumes CSS custom properties directly, so hand the components the GraphColors tokens 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.

  4. 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 computed that reads a signal fed by onLangChange, or subscribe and recompute:

    private readonly languageChange = toSignal(this.translateService.onLangChange, { initialValue: undefined });

    protected readonly chartData = computed(() => {
    this.languageChange();
    return this.buildTranslatedChartData();
    });
  5. 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, CanMatchFn or CanDeactivateFn, or a class implementing CanActivate, CanActivateChild, CanMatch or CanDeactivate — returns or emits router.createUrlTree(...). When the redirect needs navigation options such as replaceUrl, skipLocationChange or state, it returns new RedirectCommand(router.createUrlTree(...), options) instead.
  • A resolver — a ResolveFn or a class implementing Resolve — returns or emits a RedirectCommand. A UrlTree returned from a resolver becomes the resolved route data; it does not redirect.
  • Inside a resolver's observable or promise chain, such as an RxJS map callback or a helper the resolver calls, it can throw the RedirectCommand, which extends Error. The router handles it as a redirect. Guards must return or emit their redirect, including inside callbacks. Throwing a RedirectCommand bypasses 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 replaceUrl nor its skipLocationChange. 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 receives false, 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 as or satisfies assertion, or the return type of a function that creates them;
  • the canActivate, canActivateChild, canMatch, canDeactivate and resolve methods 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 Router held in a field of a base class, copied into a local (const router = this.router), or obtained through injector.get(Router);
  • guard types and the Router imported 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, loadChildren and redirectTo.

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:

  1. 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. SidebarCardComponent renders the small, medium and large sidebar card this way, and passes activeItemId only to the medium card, which is the only one that declares it.

  2. Use setInputs when 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 from app/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 undefined is 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 AccountService method that answers what the server endpoint requires, and skip the request when the answer is no. The variant job tray, for example, calls hasEditorAccess() before it loads GET /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_EDITOR includes 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, while hasAnyAuthorityDirect(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 AccountService and to MockAccountService in src/test/javascript/spec/helpers/mocks/service/mock-account.service.ts. The rule then lives in one place and its tests belong to account.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:

  1. 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.
  2. 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.
  3. 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>

Search documentation