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); -
Use the
inject()function for dependency injection instead of constructor injection: https://angular.dev/reference/migrations/inject-function -
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 UI component for a reusable control or visual pattern.
- If a reusable pattern is missing, add or evolve a TUM UI component around native HTML 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 UI primitives instead of exporting application policy as a package API.
- Use PrimeNG only when the TUM 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 or Angular CDK already provides the behavior. Follow the TUM UI package guide for ownership, public API, stories, and validation.
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 (PR #12976). Drive navigation from explicit user actions or route guards/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-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
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 deepClone from app/foundation/util/deep-clone.util (a thin wrapper over lodash cloneDeep). Never use structuredClone(), object spread ({ ...obj }), or Object.assign({}, obj) to copy an entity-like object — anything that may hold a dayjs date, a nested object, a Map/Set, or a circular reference.
import { 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';
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 |
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.setUserEnabledMemiris 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. - Merging a couple of primitive fields onto a fresh literal. Writing the literal out is clearer than cloning; the rule targets copies of existing entity objects.
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.
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
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>
Styling
Use Tailwind for application layout and TUM 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 UI/Tailwind quick reference (apply when migrating a module):
| Bootstrap | Target |
|---|---|
btn btn-primary / btn-sm / btn-outline-* / btn-group | tum-ui-button or [tumUiButton] with severity, size, and variant; tum-ui-button-group |
badge / bg-success badge | tum-ui-tag with a semantic severity |
alert alert-* | tum-ui-message with a semantic severity |
card / collapsible panel | tum-ui-card / tum-ui-panel; a semantic element with Tailwind utilities for a presentation-only box |
table table-striped table-responsive | tum-ui-table, native <table tumUiTable>, or tum-ui-table-virtual-scroll when virtualization is justified |
form-control / form-group / form-check | [tumUiInput], tum-ui-select, tum-ui-checkbox, or tum-ui-radio-button, composed with Tailwind layout |
| tabs / pagination / progress / tooltip / date picker | tum-ui-tabs, tum-ui-paginator, TUM UI progress components, [tumUiTooltip], or tum-ui-date-picker |
| modal | tum-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 because the numeric scales differ (Bootstrap mb-3 is 1rem, Tailwind mb-4 is 1rem) |
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 UI component | the component's semantic severity, such as tum-ui-message, tum-ui-tag, or tum-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 UI host-token adapter. Legacy PrimeNG also receives these
roles during migration, but it does not own them. Semantic markup and TUM 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 tum-ui-tag or
tum-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>
<tum-ui-button size="small" (clicked)="save()"> {{ 'entity.action.save' | artemisTranslate }} </tum-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 (rules/) keeps consistent: the no-bootstrap-classes ESLint glob (eslint.config.mjs) and the stylelint hex/--bs- override (.stylelintrc.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; manyrow/tablehits arep-tablestyleClassor custom classes). - Convert templates fully (never half). Use the TUM UI and Tailwind quick-reference table above,
and convert spacing by target size rather than copying the class index. Import package symbols
from
@tumaet/ui-angular, never through a deep path. - Replace legacy behavior with the target primitive. Use
tum-ui-dialogfor modal presentation,[tumUiTooltip]for tooltips, and the corresponding TUM UI control where it exists. Keep application state and results in the Artemis component. If TUM 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 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 |
| A legacy control renders grey or unstyled | a p-* root class hand-painted on a bare element | use TUM 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 UI replacements and all imports, theme setup, and PrimeUI-specific utilities are gone. The TUM 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.
Chart Instantiation
We are using the framework ngx-charts in order to instantiate charts and diagrams in Artemis.
The following is an example HTML template for a vertical bar chart:
<div #containerRef class="w-full md:col-span-9">
<ngx-charts-bar-vertical
[view]="[containerRef.offsetWidth, 300]"
[results]="ngxData"
[scheme]="color"
[legend]="false"
[xAxis]="true"
[yAxis]="true"
[yScaleMax]="20"
[roundEdges]="true"
[showDataLabel]="true"
>
<ng-template #tooltipTemplate let-model="model"> {{ labelTitle }}: {{ round((model.value / totalValue) * 100, 1) }}% </ng-template>
</ngx-charts-bar-vertical>
</div>
Here are a few tips when using this framework:
-
In order to configure the content of the tooltips in the chart, declare a ng-template with the reference
#tooltipTemplatecontaining the desired content within the selector. The framework dynamically recognizes this template. In the example above, the tooltips are configured in order to present the percentage value corresponding to the absolute value represented by the bar. Depending on the chart type, there is more than one type of tooltip configurable. For more information visit https://swimlane.gitbook.io/ngx-charts/ -
In order to manipulate the content of the data label (e.g. the text floating above a chart bar), the framework provides a
[dataLabelFormatting]property in the HTML template that can be assigned to a method. For example:[dataLabelFormatting]="formatDataLabel"with
formatDataLabel(averageScore: number): string {return averageScore + '%';}appends a percentage sign to the data label.
-
Do not override library internals with
::ng-deep. Use documented inputs, CSS custom properties, or a supported wrapper contract. If the library exposes no stable customization point, reconsider the requirement or contribute one upstream instead of coupling Artemis to private markup. -
In order to make the chart responsive in width, bind it to the width of its parent container. First, annotate the parent container with a reference (in the example
#containerRef). Then, when configuring the dimensions of the chart in[view], insertcontainerRef.offsetWidthinstead of an specific value for the width. -
There are two ways to keep axis labels and axis ticks translation-sensitive if they contain natural language:
- Axis labels are passed directly as property in the HTML template. Simply insert the translation string together with the translate pipe:
[xAxisLabel]="'artemisApp.exam.charts.xAxisLabel' | artemisTranslate" [yAxisLabel]="'artemisApp.exam.charts.yAxisLabel' | artemisTranslate"- For some chart types, the framework derives the ticks of one axis from the name property of the passed data objects.
So, these names have to be translated every time the user switches the language settings.
In this case, inject the
TranslateServiceto the underlying component and subscribe to theonLangChangeevent emitter:
private readonly translateService = inject(TranslateService);private readonly languageChange = toSignal(this.translateService.onLangChange, { initialValue: undefined });protected readonly chartData = computed(() => {this.languageChange();return this.buildTranslatedChartData();});
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;
}
}
Advanced Topics
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 [tumUiTooltip]="submittedDate | artemisDate">{{ submittedDate | amTimeAgo }}</span>
Use <span [tumUiTooltip]="submittedDate | artemisDate">{{ submittedDate | artemisTimeAgo }}</span>