@digital-blueprint/formalize-app
Version:
[GitHub Repository](https://github.com/digital-blueprint/formalize-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/formalize-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/formalize-app/) | [Formalize Bundle](https:
313 lines (266 loc) • 10.9 kB
JavaScript
import {html} from 'lit';
import * as commonUtils from '@dbp-toolkit/common/utils.js';
import {ScopedElementsMixin} from '@dbp-toolkit/common';
import {DbpBaseElement} from '@dbp-toolkit/form-elements/src/base-element.js';
import {ResourceSelect} from '@dbp-toolkit/resource-select';
import {createInstance} from '../../i18n.js';
export class CourseSelect extends ResourceSelect {
constructor() {
super();
this._courseI18n = createInstance();
this.resourcePath = '/base/courses';
this.fetchMode = 'search';
this.placeholder = this._getCoursePlaceholder();
}
update(changedProperties) {
if (changedProperties.has('lang')) {
void this._courseI18n.changeLanguage(this.lang);
this.placeholder = this._getCoursePlaceholder();
}
super.update(changedProperties);
}
_getCoursePlaceholder() {
return this._courseI18n.t('render-form.course-select.placeholder');
}
getCollectionQueryParameters(select) {
return [
['includeLocal', 'semesterKey,typeKey,lecturers'],
['filter[localData.semesterKey][operator]', 'IN'],
...CourseSelect.getSemesterKeys().map((semesterKey) => [
'filter[localData.semesterKey][value][]',
semesterKey,
]),
];
}
getSearchQueryParameters(select, searchTerm) {
return {
// Course codes do not contain dots, but CAMPUSonline displays them with dots.
search: searchTerm.replaceAll('.', '').trim(),
};
}
getItemQueryParameters(select) {
return {
includeLocal: 'semesterKey,typeKey,lecturers',
};
}
static getSemesterKeys() {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
if (currentMonth >= 10 || currentMonth <= 2) {
const winterStartYear =
currentMonth >= 10 && currentMonth <= 12 ? currentYear : currentYear - 1;
const summerYear = winterStartYear + 1;
return [`"${winterStartYear}S"`, `"${winterStartYear}W"`, `"${summerYear}S"`];
}
const yearBefore = currentYear - 1;
return [`"${yearBefore}W"`, `"${currentYear}S"`, `"${currentYear}W"`];
}
formatResource(select, course) {
return this.formatCourse(course);
}
formatCourse(course) {
const courseCode = course.code ?? '';
const courseName = course.name ?? '';
const courseType = course.localData?.typeKey ?? '';
let courseTerm = course.localData?.semesterKey ?? '';
const courseTermParts = this.parseSemesterKey(courseTerm);
if (courseTermParts) {
courseTerm =
courseTermParts.term === 'W'
? this._courseI18n.t('render-form.course-select.winter-term', {
year: courseTermParts.year,
nextYear: courseTermParts.year + 1,
})
: this._courseI18n.t('render-form.course-select.summer-term', {
year: courseTermParts.year,
});
}
return `${courseCode}: ${courseName} (${courseType}, ${courseTerm})`;
}
parseSemesterKey(semesterKey) {
if (typeof semesterKey !== 'string') return null;
const match = semesterKey.match(/^(\d{4})([WS])$/i);
if (!match) return null;
return {year: Number(match[1]), term: match[2].toUpperCase()};
}
}
export class DbpCourseSelectElement extends ScopedElementsMixin(DbpBaseElement) {
getToken() {
if (!this.auth?.token) {
throw new Error('Authentication token is not available');
}
return this.auth.token;
}
constructor() {
super();
this.entryPointUrl = null;
this.courseIdentifier = null;
// True while the course is being set programmatically (e.g. presetting a
// loaded submission). Used to suppress the `dbp-course-changed` event so
// it is only dispatched for genuine user selections.
this._presettingCourse = false;
this._userSelectedValue = null;
}
static get properties() {
return {
...super.properties,
entryPointUrl: {type: String, attribute: 'entry-point-url'},
courseIdentifier: {type: String, attribute: 'course-identifier'},
};
}
static get scopedElements() {
return {
'dbp-course-select': CourseSelect,
};
}
async fetchGermanCourseData(idPath) {
const response = await fetch(this.entryPointUrl + idPath, {
headers: {
'Content-Type': 'application/ld+json',
'Accept-Language': 'de',
Authorization: 'Bearer ' + this.getToken(),
},
});
if (!response.ok) {
return null;
} else {
try {
return await response.json();
} catch {
return null;
}
}
}
async handleInputValue(e) {
e.stopPropagation();
let courseDataObject = e.detail.object;
const originalCourseObject = courseDataObject;
// Specify the value to be included in the form submission
if (courseDataObject != null) {
// We get the course type and term from the original data object
// because we will not get the localData with the API request
let courseType = courseDataObject['localData']['typeKey'];
let courseTerm = courseDataObject['localData']['semesterKey'];
// If the language wasn't German, try to fetch German data
if (this.lang !== 'de') {
const data = await this.fetchGermanCourseData(courseDataObject['@id']);
if (data != null) {
courseDataObject = data;
}
}
let courseCode = courseDataObject['code'];
let courseName = courseDataObject['name'];
const value = `${courseCode}: ${courseName} (${courseType}, ${courseTerm})`;
if (!this._presettingCourse) {
this._userSelectedValue = value;
}
this.value = value;
} else {
if (!this._presettingCourse) {
this._userSelectedValue = '';
}
this.value = '';
}
// Suppress the event for programmatic presets (e.g. loading a submission)
// so it only fires for genuine user selections.
if (this._presettingCourse) {
this._presettingCourse = false;
return;
}
this.dispatchEvent(
new CustomEvent('dbp-course-changed', {
detail: {course: originalCourseObject},
bubbles: true,
composed: true,
}),
);
}
_extractCourseCode(courseName) {
if (!courseName) return null;
const separatorIndex = courseName.indexOf(':');
return separatorIndex === -1 ? null : courseName.slice(0, separatorIndex).trim();
}
/**
* Presets the inner CourseSelect with the course from a loaded submission.
* Uses the saved identifier when available. For older submissions, looks up
* candidates by code and uses all saved course details to find a unique match.
*
* @param {string} courseName - The formatted course name string.
* @param {string|null} courseIdentifier - The unique course identifier.
*/
async _presetCourse(courseName, courseIdentifier) {
/** @type {CourseSelect | null} */
const picker = this.renderRoot.querySelector('#' + this.name + '-picker');
if (!picker) return;
if (courseIdentifier) {
const courseId = `/base/courses/${encodeURIComponent(courseIdentifier)}`;
if (picker.value === courseId) return;
this._presettingCourse = true;
picker.value = courseId;
return;
}
const courseCode = this._extractCourseCode(courseName);
if (!courseCode) return;
// Look up the course by code to get its @id (code !== identifier)
const params = new URLSearchParams({
includeLocal: 'semesterKey,typeKey,lecturers',
'filter[foo][condition][path]': 'code',
'filter[foo][condition][operator]': 'EQUALS',
'filter[foo][condition][value]': `"${courseCode}"`,
});
const resp = await fetch(`${this.entryPointUrl}/base/courses?${params.toString()}`, {
headers: {
Authorization: 'Bearer ' + this.getToken(),
'Accept-Language': 'de',
},
});
if (!resp.ok) return;
const data = await resp.json();
const matchingCourses = (data['hydra:member'] ?? []).filter(
(course) =>
`${course.code}: ${course.name} (${course.localData?.typeKey}, ${
course.localData?.semesterKey
})` === courseName,
);
if (matchingCourses.length !== 1) return;
const course = matchingCourses[0];
if (!course?.['@id']) return;
// Skip if the picker already holds this course to avoid redundant
// change events on re-renders (e.g. when `auth` gets a new reference).
if (picker.value === course['@id']) return;
// Mark this as a programmatic change so `handleInputValue` does not
// dispatch `dbp-course-changed` (which would reset dependent fields).
this._presettingCourse = true;
picker.value = course['@id'];
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('value') && this._userSelectedValue === this.value) {
this._userSelectedValue = null;
return;
}
if (
(changedProperties.has('value') ||
changedProperties.has('courseIdentifier') ||
changedProperties.has('auth')) &&
typeof this.value === 'string' &&
this.value &&
this.auth?.token
) {
void this._presetCourse(this.value, this.courseIdentifier);
}
}
renderInput() {
return html`
<div class="control">
<dbp-course-select
id="${this.name}-picker"
name="${this.name}Picker"
subscribe="lang,auth,entry-point-url"
="${(e) => this.handleInputValue(e)}"></dbp-course-select>
</div>
`;
}
}
commonUtils.defineCustomElement('dbp-course-select-element', DbpCourseSelectElement);