Files
compiler-explorer/static/widgets/compiler-overrides.ts
Matt Godbolt 637564f389 Migrate to Bootstrap 5 (#7582)
This PR completes the migration from Bootstrap 4 to Bootstrap 5.3.5
following the plan outlined in
[docs/Bootstrap5Migration.md](https://github.com/compiler-explorer/compiler-explorer/blob/mg/bootstrap5/docs/Bootstrap5Migration.md).

## Migration Process

We followed a phased approach as documented in the migration plan:

1. **Phase 1: Dependency Updates and Basic Setup**
   - Updated Bootstrap from 4.6.2 to 5.3.5
   - Added @popperjs/core dependency (replacing Popper.js)
   - Updated Tom Select theme from bootstrap4 to bootstrap5

2. **Phase 2: Global CSS Class Migration**
   - Updated directional utility classes (ml/mr → ms/me)
- Updated floating utility classes (float-left/right → float-start/end)
   - Updated text alignment classes (text-left/right → text-start/end)

3. **Phase 3: HTML Attribute Updates**
- Updated data attributes to use Bootstrap 5 prefixes (data-bs-toggle,
data-bs-target, etc.)
   - Fixed tab navigation issues

4. **Phase 4: JavaScript API Compatibility Layer**
   - Created bootstrap-utils.ts compatibility layer
- Updated component initialization for modals, dropdowns, popovers, etc.

5. **Phase 5: Component Migration**
- Updated and tested specific components (modals, dropdowns, toasts,
etc.)
   - Fixed styling issues in cards and button groups

6. **Phase 6: Form System Updates**
   - Updated form control classes to Bootstrap 5 standards
   - Updated checkbox/radio markup patterns
   - Simplified input groups

7. **Phase 7: Navbar Structure Updates**
   - Updated navbar structure with container-fluid
   - Fixed responsive behavior

8. **Phase 8: SCSS Variables and Theming**
   - Added custom CSS fixes for navbar alignment
   - Verified theme compatibility

9. **Phase 9: Accessibility Improvements**
   - Updated sr-only to visually-hidden
   - Added proper ARIA attributes
   - Enhanced screen reader support

## Key Changes

- No more jQuery dependency in Bootstrap 5
- New prefix for data attributes (data-bs-*)
- Improved accessibility with ARIA attributes
- Updated positioning classes (start/end instead of left/right)
- Simplified input group structure

## Test Plan

1. **Navigation Testing**
   - Verify all dropdown menus open and close properly
   - Test mobile menu responsiveness
   - Check tab navigation in settings dialog

2. **Component Testing**
- Verify all modals open and close correctly (settings, share,
load/save)
   - Test tooltips and popovers
   - Check form controls in different dialogs

3. **Layout Testing**
   - Test responsiveness on different screen sizes
   - Verify proper alignment of elements
   - Check dark mode compatibility

4. **Specific Features to Test**
   - Compiler selection and options
   - Share dialog functionality
   - Settings dialog
   - Tree view (IDE mode)
   - Font selection dropdown

5. **Browser Testing**
   - Test in Chrome, Firefox, Safari
   - Test in mobile browsers

## Note on Further Improvements

After this migration is stable, we could consider Phase 12: removing
jQuery dependency entirely, as Bootstrap 5 no longer requires it. This
would be a separate effort.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-04-24 12:10:37 -05:00

435 lines
16 KiB
TypeScript

// Copyright (c) 2023, Compiler Explorer Authors
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
import $ from 'jquery';
import {
CompilerOverrideType,
ConfiguredOverride,
ConfiguredOverrides,
EnvVarOverrides,
} from '../../types/compilation/compiler-overrides.interfaces.js';
import {assert, unwrap} from '../assert.js';
import * as BootstrapUtils from '../bootstrap-utils.js';
import {CompilerInfo} from '../compiler.interfaces.js';
import {localStorage} from '../local.js';
import {options} from '../options.js';
const FAV_OVERRIDES_STORE_KEY = 'favoverrides';
export type CompilerOverridesChangeCallback = () => void;
type FavOverride = {
name: CompilerOverrideType;
value: string;
meta: string;
};
type FavOverrides = FavOverride[];
class IncompatibleState {
constructor(reason: string) {
this.reason = reason;
}
reason: string;
}
class InactiveState {}
class ActiveState {}
type OverrideState = IncompatibleState | InactiveState | ActiveState;
export class CompilerOverridesWidget {
private popupDomRoot: JQuery<HTMLElement>;
private envVarsInput: JQuery<HTMLElement>;
private dropdownButton: JQuery;
private onChangeCallback: CompilerOverridesChangeCallback;
private configured: ConfiguredOverrides = [];
private compiler: CompilerInfo | undefined;
constructor(dropdownButton: JQuery, onChangeCallback: CompilerOverridesChangeCallback) {
this.popupDomRoot = $('#overrides-selection');
this.dropdownButton = dropdownButton;
this.envVarsInput = this.popupDomRoot.find('.envvars');
this.onChangeCallback = onChangeCallback;
}
private loadStateFromUI(): ConfiguredOverrides {
const overrides: ConfiguredOverrides = [];
const envOverrides = this.getEnvOverrides();
if (envOverrides.length > 0) {
overrides.push({
name: CompilerOverrideType.env,
values: envOverrides,
});
}
const selects = this.popupDomRoot.find('select');
for (const select of selects) {
const jqSelect = $(select);
const rawName = jqSelect.attr('name');
const val = jqSelect.val();
if (val) {
const name = rawName as CompilerOverrideType;
assert(name !== CompilerOverrideType.env);
overrides.push({
name: name,
value: val.toString(),
});
}
}
return overrides;
}
private envvarsToString(envVars: EnvVarOverrides): string {
return envVars.map(env => `${env.name}=${env.value}`).join('\n');
}
private stringToEnvvars(envVars: string): EnvVarOverrides {
return envVars
.split('\n')
.map(env => {
const firstEqPos = env.indexOf('=');
if (firstEqPos !== -1) {
return {
name: env.substring(0, firstEqPos),
value: env.substring(firstEqPos + 1),
};
}
return false;
})
.filter(Boolean) as EnvVarOverrides;
}
private getEnvOverrides(): EnvVarOverrides {
return this.stringToEnvvars(this.envVarsInput.val() as string);
}
private selectOverrideFromFave(event) {
const elem = $(event.target).parent();
const name = elem.data('ov-name');
const value = elem.data('ov-value');
const possibleOverride = this.compiler?.possibleOverrides?.find(ov => ov.name === name);
if (possibleOverride) {
const override = possibleOverride.values.find(v => v.value === value);
if (override) {
const currentOverrides = this.loadStateFromUI();
const configOv = currentOverrides.find(ov => ov.name === name);
if (configOv) {
assert(configOv.name !== CompilerOverrideType.env);
// If it is already enabled, clear the value.
configOv.value = configOv.value === value ? '' : value;
} else {
currentOverrides.push({
name: name,
value: value,
});
}
this.loadStateIntoUI(currentOverrides);
}
}
}
private newFavoriteOverrideDiv(fave: FavOverride, state: OverrideState) {
const div = $('#overrides-favorite-tpl').children().clone();
const prefix = fave.name + ': ';
const btn = div.find('.overrides-name');
btn.html(prefix + fave.value);
if (state instanceof ActiveState) {
btn.addClass('active');
} else if (state instanceof IncompatibleState) {
btn.prop('disabled', true);
btn.prop('data-bs-toggle', 'tooltip');
btn.prop('data-bs-placement', 'top');
btn.prop('title', state.reason);
}
div.data('ov-name', fave.name);
div.data('ov-value', fave.value);
div.on('click', this.selectOverrideFromFave.bind(this));
return div;
}
private loadFavoritesIntoUI() {
const favoritesDiv = this.popupDomRoot.find('.overrides-favorites');
favoritesDiv.html('');
const faves = this.getFavorites();
const current_overrides = this.get();
for (const fave of faves) {
let state: OverrideState = new IncompatibleState(
'This override is not compatible with the current compiler.',
);
const possible = this.compiler?.possibleOverrides?.find(ov => ov.name === fave.name);
if (possible) {
state = new InactiveState();
if (!possible.values.find(ov => ov.value === fave.value)) {
state = new IncompatibleState(
'The value of this override is not compatible with the current compiler.',
);
} else if (
current_overrides?.find(ov => {
return ov.name !== CompilerOverrideType.env && ov.name === fave.name && ov.value === fave.value;
})
) {
state = new ActiveState();
}
}
const div: any = this.newFavoriteOverrideDiv(fave, state);
favoritesDiv.append(div);
}
}
private addToFavorites(override: ConfiguredOverride) {
if (override.name === CompilerOverrideType.env || !override.value) return;
const faves = this.getFavorites();
const fave: FavOverride = {
name: override.name,
value: override.value,
meta: this.compiler?.baseName || this.compiler?.groupName || this.compiler?.name || this.compiler?.id || '',
};
faves.push(fave);
this.setFavorites(faves);
}
private removeFromFavorites(override: ConfiguredOverride) {
if (override.name === CompilerOverrideType.env || !override.value) return;
const faves = this.getFavorites();
const faveIdx = faves.findIndex(f => f.name === override.name && f.value === override.value);
if (faveIdx !== -1) {
faves.splice(faveIdx, 1);
this.setFavorites(faves);
}
}
private isAFavorite(override: ConfiguredOverride) {
if (override.name === CompilerOverrideType.env || !override.value) return false;
const faves = this.getFavorites();
const fave = faves.find(f => f.name === override.name && f.value === override.value);
return !!fave;
}
private loadStateIntoUI(configured: ConfiguredOverrides) {
this.envVarsInput.val('');
for (const config of configured) {
if (config.name === CompilerOverrideType.env) {
this.envVarsInput.val(this.envvarsToString(config.values));
}
}
const container = this.popupDomRoot.find('.possible-overrides');
container.html('');
if (this.compiler?.possibleOverrides) {
for (const possibleOverride of this.compiler.possibleOverrides) {
const card = $('#possible-override').children().clone();
card.find('.override-name').html(possibleOverride.display_title);
card.find('.override-description').html(possibleOverride.description);
const select = card.find<HTMLSelectElement>('.override select');
select.attr('name', possibleOverride.name);
const faveButton = card.find('.override-fav-button');
const faveStar = faveButton.find('.override-fav-btn-icon');
faveButton.hide();
const config = configured.find(c => c.name === possibleOverride.name);
let option = $('<option />');
select.append(option);
for (const value of possibleOverride.values) {
option = $('<option />');
option.html(value.name);
option.val(value.value);
if (
config &&
config.name !== CompilerOverrideType.env &&
config.value &&
config.value === value.value
) {
option.attr('selected', 'selected');
if (this.isAFavorite(config)) {
faveStar.removeClass('far').addClass('fas');
}
faveButton.show();
}
select.append(option);
}
select.off('change').on('change', () => {
const option = select.find('option:selected');
if (option.length > 0) {
const value = unwrap(option.val()).toString();
const name = possibleOverride.name;
assert(name !== CompilerOverrideType.env);
const ov: ConfiguredOverride = {
name: name,
value: value,
};
if (this.isAFavorite(ov)) {
faveStar.removeClass('far').addClass('fas');
} else {
faveStar.removeClass('fas').addClass('far');
}
if (ov.value !== '') {
faveButton.show();
} else {
faveButton.hide();
}
}
this.configured = this.loadStateFromUI();
this.loadFavoritesIntoUI();
});
faveButton.on('click', () => {
const option = select.find('option:selected');
if (option.length > 0) {
const value = unwrap(option.val()).toString();
const name = possibleOverride.name;
assert(name !== CompilerOverrideType.env);
const ov: ConfiguredOverride = {name, value};
if (this.isAFavorite(ov)) {
this.removeFromFavorites(ov);
faveStar.removeClass('fas').addClass('far');
} else {
this.addToFavorites(ov);
faveStar.removeClass('far').addClass('fas');
}
}
this.loadFavoritesIntoUI();
});
container.append(card);
}
}
this.configured = configured;
this.loadFavoritesIntoUI();
}
set(configured: ConfiguredOverrides) {
this.configured = configured;
this.updateButton();
}
setDefaults() {
this.configured = [];
if (this.compiler?.possibleOverrides) {
for (const ov of this.compiler.possibleOverrides) {
if (ov.name !== CompilerOverrideType.env && ov.default) {
this.configured.push({
name: ov.name,
value: ov.default,
});
}
}
}
this.updateButton();
}
setCompiler(compilerId: string, languageId?: string) {
this.compiler = options.compilers.find(c => c.id === compilerId);
}
get(): ConfiguredOverrides | undefined {
if (this.compiler) {
return this.configured;
}
return undefined;
}
private getFavorites(): FavOverrides {
return JSON.parse(localStorage.get(FAV_OVERRIDES_STORE_KEY, '[]'));
}
private setFavorites(faves: FavOverrides) {
localStorage.set(FAV_OVERRIDES_STORE_KEY, JSON.stringify(faves));
}
private updateButton() {
const selected = this.get();
if (selected && selected.length > 0) {
this.dropdownButton
.addClass('btn-success')
.removeClass('btn-light')
.prop(
'title',
'Current overrides:\n' +
selected
.map(ov => {
let line = '- ' + ov.name;
if (ov.name !== CompilerOverrideType.env && ov.value) {
line += ' = ' + ov.value;
}
return line;
})
.join('\n'),
);
} else {
this.dropdownButton.removeClass('btn-success').addClass('btn-light').prop('title', 'Overrides');
}
}
show() {
this.loadStateIntoUI(this.configured);
const lastOverrides = JSON.stringify(this.configured);
// popup is shared, so clear the events first
BootstrapUtils.setElementEventHandler(this.popupDomRoot, 'hidden.bs.modal', () => {
this.configured = this.loadStateFromUI();
const newOverrides = JSON.stringify(this.configured);
if (lastOverrides !== newOverrides) {
this.updateButton();
this.onChangeCallback();
}
});
BootstrapUtils.showModal(this.popupDomRoot);
}
}