Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ckeditor container shows Rich Text Editor text and doesn't wrap or size toolbar properly in angular #16647

Closed
HelainaCurtis opened this issue Jul 1, 2024 · 3 comments
Labels
domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. resolution:resolved This issue was already resolved (e.g. by another ticket). type:bug This issue reports a buggy (incorrect) behavior.

Comments

@HelainaCurtis
Copy link

Reproduction steps

  1. …installed "ckeditor5-custom-build previously(worked as expected)
  2. …removed references of "ckeditor5-custom-build to try and run npm update
  3. … following ckeditor 5 builder instructions: ran npm install ckeditor and npm install ckeditor-angular
  4. placed css from instructions in styles.scss
  5. Modified our .ts file to set up config in ngafterviewinit
  6. At this point npm start was giving error for loader on importing ckeditors.css in the .ts file so placed '@ import 'ckeditor5/ckeditor5.css'; in component's scss file instead errorcss
  7. replaced one of our ckeditor fields with exact copy from instructions and the other replaced what was needed

Current behavior

This is what occurs on one of our ckeditor fields: All the toolbar pieces work on the text, but the box around the text is invisible and the toolbar is stacking very oddly. There is also always a Rich Text Editor text above the toolbar
stacking

Expected behavior

Expected our old functionality: old

Definition of Done

  • Please leave this field for us. We will fill it during the backlog refinement session.

Relevant debug data

html of component:
 <forge-card
  class="project-info-card"
  outlined="true"
  *ngIf="{
    users: this.usersStore.users$ | async,
    project: this.projectDetailsService.project$ | async,
    projectTypes: this.projectTypesStore.projectTypesWithUnselectedOption$ | async,
    priorities: this.prioritiesStore.priorities$ | async,
    strategicGoals: this.strategicGoalsStore.strategicGoals$ | async,
    showSkeletonLoaders: showSkeletonLoaders$ | async,
    statusOptions: statusOptions$ | async
  } as state"
>
  <forge-scaffold>
    <ng-container *ngIf="state.showSkeletonLoaders; else body">
      <forge-skeleton list-item *ngFor="let iterator of [0, 1, 2, 3, 4, 5, 6, 7]"></forge-skeleton>
    </ng-container>
    <ng-template #body>
      <forge-expansion-panel #tab slot="body" [open]="true">
        <button
          slot="header"
          class="forge-expansion-panel__button expansion-panel-button"
          role="button"
          aria-expanded="true"
          aria-controls="expansion-panel-content"
        >
          <div class="title forge-typography--headline5">Project information</div>
          <forge-open-icon></forge-open-icon>
        </button>
        <div id="expansion-panel-content" role="group">
          <forge-divider class="header-body-divider"></forge-divider>
          <div slot="body" class="project-info-card-body">
            <form class="form-container" [formGroup]="formGroup">
              <div class="grid-container">
                <forge-text-field
                  class="text-field__name"
                  required="true"
                  [invalid]="
                    formGroup.controls['projectName'].errors?.maxlength ||
                    (formGroup.controls['projectName'].errors?.required && formGroup.controls['projectName'].touched)
                  "
                >
                  <label for="projectName">Project name</label>
                  <input
                    type="text"
                    #projectName
                    id="projectName"
                    tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit"
                    formControlName="projectName"
                  />
                  <span *ngIf="formGroup.controls['projectName'].errors?.maxlength" slot="helper-text"> Maximum length 80 characters.</span>
                  <span *ngIf="formGroup.controls['projectName'].errors?.required && formGroup.controls['projectName'].touched" slot="helper-text">
                    Project name is required.
                  </span>
                </forge-text-field>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Status" formControlName="status" required="true">
                  <forge-option *ngFor="let status of state.statusOptions" [value]="status.value">{{ status.label }}</forge-option>
                </forge-select>

                <forge-text-field class="text-field__name text-field__helper-text" [invalid]="formGroup.controls['location'].errors?.maxlength">
                  <label for="location">Location</label>
                  <input type="text" id="location" tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" formControlName="location" />
                  <forge-icon-button slot="addon-end">
                    <button
                      type="button"
                      aria-label="Retrieve coordinates"
                      (click)="retrieveCoordinatesClicked()"
                      [disabled]="formGroup.controls['location'].errors !== null || formGroup.controls['location'].value === originalLocationValue"
                    >
                      <forge-icon name="magnify"></forge-icon>
                    </button>
                  </forge-icon-button>
                  <span *ngIf="formGroup.controls['location'].errors?.maxlength" slot="helper-text"> Maximum length 80 characters.</span>
                  <span *ngIf="!formGroup.controls['location'].errors?.maxlength" slot="helper-text">Enter the project address or location description.</span>
                </forge-text-field>

                <forge-text-field class="text-field__helper-text">
                  <label for="project-number">Project number</label>
                  <input type="text" id="project-number" value="{{ state.project.budgetProjectNumber }}" disabled="true" />
                  <span slot="helper-text">Project number is automatically assigned.</span>
                </forge-text-field>

                <forge-autocomplete [filter]="departmentFilter" formControlName="department">
                  <forge-text-field>
                    <input class="department truncate-text" type="text" tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" />
                    <label for="department">Department</label>
                    <forge-icon slot="trailing" name="arrow_drop_down" data-forge-dropdown-icon></forge-icon>
                    <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="departmentInfo"></forge-icon>
                    <forge-tooltip target="#departmentInfo" position="bottom">{{ formGroup.get('department').value?.description }}</forge-tooltip>
                  </forge-text-field>
                </forge-autocomplete>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Project manager" formControlName="projectManager">
                  <forge-option *ngFor="let user of state.users" [value]="user">{{ user.fullName }}</forge-option>
                </forge-select>

                <forge-text-field [invalid]="formGroup.controls['externalProjectNumber'].errors?.maxlength">
                  <label for="external-project-number">External project number</label>
                  <input
                    type="text"
                    id="external-project-number"
                    tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit"
                    formControlName="externalProjectNumber"
                  />
                  <span *ngIf="formGroup.controls['externalProjectNumber'].errors?.maxlength" slot="helper-text">Maximum length is 80 characters.</span>
                </forge-text-field>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Project type" formControlName="projectType">
                  <forge-option *ngFor="let projectType of state.projectTypes" [value]="projectType">{{ projectType.name }}</forge-option>
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="projectTypeInfo"></forge-icon>
                  <forge-tooltip target="#projectTypeInfo" position="bottom">{{ formGroup.get('projectType').value.description }}</forge-tooltip>
                </forge-select>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Strategic goal" formControlName="strategicGoal">
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="goalInfo"></forge-icon>
                  <forge-tooltip target="#goalInfo" position="bottom">{{ formGroup.get('strategicGoal').value?.description }}</forge-tooltip>
                  <forge-option *ngFor="let strategicGoal of state.strategicGoals" [value]="strategicGoal">{{ strategicGoal.name }}</forge-option>
                </forge-select>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Priority" formControlName="priority">
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="priorityInfo"></forge-icon>
                  <forge-tooltip target="#priorityInfo" position="bottom">{{ formGroup.get('priority').value?.description }}</forge-tooltip>
                  <forge-option *ngFor="let priority of state.priorities" [value]="priority">{{ priority.name }}</forge-option>
                </forge-select>

                <forge-date-picker formControlName="projectedStartDate" value-mode="iso-string" id="projectedStartDate" #projectedStartDate>
                  <forge-text-field [invalid]="formGroup.get('projectedStartDate').value > formGroup.get('projectedEndDate').value">
                    <label for="projectedStartDate" slot="label">Projected start date</label>
                    <input tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" type="text" id="projectedStartDate" placeholder="mm/dd/yyyy" />
                    <span *ngIf="formGroup.get('projectedStartDate').value > formGroup.get('projectedEndDate').value" slot="helper-text"
                      >The start date must be before the end date.</span
                    >
                  </forge-text-field>
                </forge-date-picker>

                <forge-date-picker formControlName="projectedEndDate" value-mode="iso-string" id="projectedEndDate" #projectedStartDate>
                  <forge-text-field [invalid]="formGroup.get('projectedEndDate').value < formGroup.get('projectedStartDate').value">
                    <label for="projectedEndDate" slot="label">Projected end date</label>
                    <input tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" type="text" id="projectedEndDate" placeholder="mm/dd/yyyy" />
                    <span *ngIf="formGroup.get('projectedEndDate').value < formGroup.get('projectedStartDate').value" slot="helper-text"
                      >The end date must be after the start date.</span
                    >
                  </forge-text-field>
                </forge-date-picker>
              </div>
              <div class="ckEditor">
                <label class="ckEditor__label" for="description">Description</label>
                <div class="editor-container editor-container_classic-editor" #editorContainerElement>
                  <div class="editor-container__editor">
                    <div #editorElement>
                      <ckeditor
                        *ngIf="isLayoutReady"
                        class="ckEditor__input"
                        id="description"
                        formControlName="description"
                        [editor]="editor"
                        [config]="editorConfig"
                      ></ckeditor>
                    </div>
                  </div>
                </div>
              </div>

              <div class="ckEditor">
                <label class="ckEditor__label" for="justification">Justification</label>
                <ckeditor
                  *ngIf="isLayoutReady"
                  class="ckEditor__input"
                  id="justification"
                  formControlName="justification"
                  [editor]="editor"
                  [config]="editorConfig"
                ></ckeditor>
              </div>
            </form>
          </div>
        </div>
      </forge-expansion-panel>
    </ng-template>
  </forge-scaffold>
</forge-card>

scss file of component: .expansion-panel-button {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}

header-body-divider {
  padding-bottom: 16px;
}

#expansion-panel-content > div[slot='body'] {
  padding: 16px;
  display: grid;
  gap: 16px;
  padding-top: 16px;
}

.grid-container {
  display: grid;
  grid-template-columns: repeat(3, minmax(20vw, 33vw));
  grid-template-rows: auto;
  gap: 32px 16px;
}

forge-card.project-info-card {
  --forge-card-padding: 0px;
}

.text-field__name {
  grid-column: span 2;
}

.text-field__helper-text {
  margin-bottom: -24px;
}

.ckEditor {
  padding-top: 12px;
}

.ckEditor__label {
  color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54));
}

.ckEditor__input {
  width: 100%;
}

.infoIcon {
  padding: 1.5px;
}

:host ::ng-deep .ck-editor__editable_inline {
  min-height: 112px !important;
  max-height: 180px;
  overflow: auto;
}

.truncate-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
@import 'ckeditor5/ckeditor5.css';

ts of component:
 import {
  AfterViewInit,
  Component,
  ElementRef,
  EventEmitter,
  Input,
  OnDestroy,
  OnInit,
  Output,
  ViewChild,
  ChangeDetectorRef,
} from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Observable, filter, lastValueFrom, map, of, startWith, take } from 'rxjs';
import { BusyState } from 'tipe-core-components';
import { IAutocompleteOption, IOption } from '@tylertech/forge';
import { DepartmentsStore } from 'src/app/store/departments/departments-store';
import { ProjectDetailsService } from 'src/app/store/project-details/project-details.service';
import { ProjectTypesStore } from 'src/app/store/project-types/project-types-store.module';
import { UsersStore } from 'src/app/store/users/users-store';
import { PrioritiesStore } from 'src/app/store/priorities/priorities-store.module';
import { StrategicGoalsStore } from 'src/app/store/strategic-goals/strategic-goals-store.module';
import { Subject, takeUntil } from 'rxjs';
import { IDepartments } from 'src/app/store/models/departments.model';
import { STATUS_LABEL } from 'src/app/store/models/project.models';
import { ActivatedRoute } from '@angular/router';
import { TableUtils } from 'budget-shared-components';
import {
  ClassicEditor,
  AccessibilityHelp,
  Autosave,
  Bold,
  Essentials,
  FontBackgroundColor,
  FontColor,
  FontFamily,
  FontSize,
  Heading,
  Indent,
  IndentBlock,
  Italic,
  List,
  Paragraph,
  SelectAll,
  Undo,
  EditorConfig,
} from 'ckeditor5';

@Component({
  selector: 'project-info',
  templateUrl: './project-info.component.html',
  styleUrls: ['./project-info.component.scss'],
})
export class ProjectInfoComponent implements OnDestroy, OnInit, AfterViewInit {
  @Input() formGroup: FormGroup;
  @Output() retrieveCoordinates: EventEmitter<void> = new EventEmitter();
  @ViewChild('projectName') projectNameInput: ElementRef;
  public showSkeletonLoaders$: Observable<boolean>;
  public isLayoutReady = false;
  public editor = ClassicEditor;
  public editorConfig: EditorConfig = {};

  private _unsubscribe$ = new Subject<void>();
  public departmentFilter = (filter: string, selectedAccountId: number): Promise<IAutocompleteOption<IDepartments>[]> =>
    this.checkDepartmentsLoaded(filter, selectedAccountId);
  public departments: IDepartments[];
  public statusOptions: IOption[] = [];
  public statusOptions$: Observable<IOption[]>;
  public originalLocationValue: string;

  constructor(
    public usersStore: UsersStore,
    public prioritiesStore: PrioritiesStore,
    public strategicGoalsStore: StrategicGoalsStore,
    public projectDetailsService: ProjectDetailsService,
    public projectTypesStore: ProjectTypesStore,
    public departmentsStore: DepartmentsStore,
    private _activatedRoute: ActivatedRoute,
    private _changeDetector: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    this._setupSkeletonLoaders();
    this.loadDetails();
    this._setupStatusLabels();
    this._setupOriginalLocationValue();
  }

  ngOnDestroy(): void {
    this._unsubscribe$.next();
    this._unsubscribe$.complete();
  }

  ngAfterViewInit(): void {
    this.projectNameInput.nativeElement.focus();
    this.editorConfig = {
      toolbar: {
        items: [
          'undo',
          'redo',
          '|',
          'selectAll',
          '|',
          'heading',
          '|',
          'fontSize',
          'fontFamily',
          'fontColor',
          'fontBackgroundColor',
          '|',
          'bold',
          'italic',
          '|',
          'bulletedList',
          'numberedList',
          'indent',
          'outdent',
          '|',
          'accessibilityHelp',
        ],
        shouldNotGroupWhenFull: false,
      },
      plugins: [
        AccessibilityHelp,
        Autosave,
        Bold,
        Essentials,
        FontBackgroundColor,
        FontColor,
        FontFamily,
        FontSize,
        Heading,
        Indent,
        IndentBlock,
        Italic,
        List,
        Paragraph,
        SelectAll,
        Undo,
      ],
      fontFamily: {
        supportAllValues: true,
      },
      fontSize: {
        options: [10, 12, 14, 'default', 18, 20, 22],
        supportAllValues: true,
      },
      heading: {
        options: [
          {
            model: 'paragraph',
            title: 'Paragraph',
            class: 'ck-heading_paragraph',
          },
          {
            model: 'heading1',
            view: 'h1',
            title: 'Heading 1',
            class: 'ck-heading_heading1',
          },
          {
            model: 'heading2',
            view: 'h2',
            title: 'Heading 2',
            class: 'ck-heading_heading2',
          },
          {
            model: 'heading3',
            view: 'h3',
            title: 'Heading 3',
            class: 'ck-heading_heading3',
          },
          {
            model: 'heading4',
            view: 'h4',
            title: 'Heading 4',
            class: 'ck-heading_heading4',
          },
          {
            model: 'heading5',
            view: 'h5',
            title: 'Heading 5',
            class: 'ck-heading_heading5',
          },
          {
            model: 'heading6',
            view: 'h6',
            title: 'Heading 6',
            class: 'ck-heading_heading6',
          },
        ],
      },
    };

    this.isLayoutReady = true;
    this._changeDetector.detectChanges();
  }

  retrieveCoordinatesClicked(): void {
    this.retrieveCoordinates.emit(null);
  }

  private _setupOriginalLocationValue(): void {
    this.projectDetailsService.project$
      .pipe(takeUntil(this._unsubscribe$))
      .subscribe((project) => (this.originalLocationValue = project.location));
  }

  private _setupStatusLabels(): void {
    STATUS_LABEL.forEach((value, key) => {
      this.statusOptions.push({
        value: STATUS_LABEL.get(key),
        label: value,
      });
    });
    this.statusOptions$ = of(this.statusOptions);
  }

  private _setupSkeletonLoaders(): void {
    const projectId = Number(this._activatedRoute.snapshot.paramMap.get('projectId'));
    this.showSkeletonLoaders$ = this.projectDetailsService.busyState$.pipe(
      filter((s) => s === BusyState.NONE),
      take(1),
      map(() => false),
      startWith(true)
    );
  }

  loadDetails(): void {
    this.departmentsStore.departments$
      .pipe(takeUntil(this._unsubscribe$))
      .subscribe((departments) => (this.departments = departments));
  }

  getLookupForDepartmentId(typeInFilter: string, selectedDepartmentId: number): IAutocompleteOption<IDepartments>[] {
    return this.departments
      .filter(
        (department) =>
          (selectedDepartmentId > 0 && department.id === selectedDepartmentId) ||
          department.name.toString().toLowerCase().includes(typeInFilter.toLowerCase())
      )
      .map((department) => {
        const option: any = {
          label: department.name,
          value: department,
        };
        return option;
      })
      .sort((option1, option2) =>
        TableUtils.comparator(option1.value.name, option2.value.name, 'string')
      ) as IAutocompleteOption<IDepartments>[];
  }

  async checkDepartmentsLoaded(
    typeInFilter: string,
    selectedDepartmentId: number
  ): Promise<IAutocompleteOption<IDepartments>[]> {
    const result: Observable<IAutocompleteOption<IDepartments>[]> = new Observable((subscriber) =>
      this.departmentsStore.busyState$
        .pipe(
          filter((busyState) => busyState === BusyState.NONE),
          take(1)
        )
        .subscribe(() => {
          subscriber.next(this.getLookupForDepartmentId(typeInFilter, selectedDepartmentId));
          subscriber.complete();
        })
    );

    return await lastValueFrom(result);
  }
}

Other details

We use custom schema on our html so you will see other elements in the html that are unfamiliar. We use ngrx and I've left the references, but none of the functionality is the issue, code is included and left as is for transparency.

User agent

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0

@HelainaCurtis HelainaCurtis added squad:platform Issue to be handled by the Platform team. type:bug This issue reports a buggy (incorrect) behavior. labels Jul 1, 2024
@pomek pomek removed the squad:platform Issue to be handled by the Platform team. label Jul 2, 2024
@Witoso
Copy link
Member

Witoso commented Jul 2, 2024

Hi! The new installation methods expect that the CSS will be loaded separately. The screenshot suggests that the stylesheets were not loaded.

There may be two different solutions here:

  1. giving error for loader on importing ckeditors.css,
    1. add a webpack CSS loader, so that the webpack correctly loads the CSS.
  2. '@ import 'ckeditor5/ckeditor5.css'; in component's scss file instead
    1. it's possible that SCSS cannot resolve this path, the exact file is under node_modules/ckeditor5/dist/ckeditor.css

Please investigate, and pick the option that fits your setup. The solution might also depend on your Angular setup or version. Let me know if it helped.

@Witoso Witoso added pending:feedback This issue is blocked by necessary feedback. domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. labels Jul 2, 2024
@HelainaCurtis
Copy link
Author

HelainaCurtis commented Jul 2, 2024

Thanks so much for the quick response! I tried the 2nd solution but it did not work.
I tried the 1st solution, installed css-loader and style-loader. Our project is angular 17, and does not use a webpack.config.js. So I tried to use the inline style specified here: https://webpack.js.org/concepts/loaders/#inline
Thus, adding this in the .ts file where theckeditor.css import is suggested
import '!style-loader!css-loader!/home/helaina/pathToRepo/App/client/node_modules/ckeditor5/dist/ckeditor5.css';
I still need to change the path to not use my own computers path but this made the ckeditors load correctly at least:
fixed

@CKEditorBot CKEditorBot removed the pending:feedback This issue is blocked by necessary feedback. label Jul 3, 2024
@HelainaCurtis
Copy link
Author

Got the path working, Thanks for the directions and help

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. resolution:resolved This issue was already resolved (e.g. by another ticket). type:bug This issue reports a buggy (incorrect) behavior.
Projects
None yet
Development

No branches or pull requests

4 participants