emberjs child component not re-rendering after property update - ember.js

I have a page component (five-whys) with a number of inputs that the user can choose to finalize the input. When the user clicks finalize, all questions are made to be disabled.
Page component
five-whys.hbs:
{{#each this.whys as |why i|}}
<Generic::RichTextInput
#value={{why.content}}
#onChange={{action this.whyChanged i}}
#disabled={{this.isFinalized}} />
{{/each}}
<button {{on "click" this.finalizeWhy}}>Finalize</button>
five-whys.ts
interface AnalyzeFiveWhysArgs {
dataStory: DataStory;
}
export default class AnalyzeFiveWhys extends Component<AnalyzeFiveWhysArgs> {
#alias("args.dataStory.fiveWhysAnalysis") fiveWhysAnalysis
#tracked
isFinalized: boolean = this.fiveWhysAnalysis.isFinalized ?? false;
#tracked
whys: LocalWhy[] = this.fiveWhysAnalysis.whys;
#tracked
isFinalized: boolean = this.fiveWhysAnalysis.isFinalized ?? false;
#action
async finalizeWhy() {
this.isFinalized = true;
}
This works fine when my rich text component is just a regular text area. However, I am trying to implement tinymce which requires me to do stuff outside of Embers little safe space of magic.
My rich text component:
Template:
<textarea id={{this.id}} disabled={{this.templatePieceIsDisabled}}>{{#value}}</textarea>
Typescript:
interface GenericRichTextInputArgs {
value?: string;
onChange: (value: string) => void;
name: string;
disabled?: boolean;
}
export default class GenericRichTextInput extends Component<GenericRichTextInputArgs> {
constructor(owner: unknown, args: GenericRichTextInputArgs) {
super(owner, args);
this.initializeTinymce();
}
id = this.args.name;
get editor() {
return tinymce.get(this.id);
}
get settings() {
console.log(this.args.disabled);
const settings: TinyMCESettings = {
selector: `#${this.id}`,
setup: (editor: Editor) => this.setupEditor(this, editor),
readonly: this.args.disabled ? this.args.disabled : false
};
return settings;
}
initializeTinymce() {
Ember.run.schedule('afterRender', () => {
console.log("re-initializing"); // I expect to see this log every time the isFinalized property in the five-whys component changes. But I only see it on page load.
tinymce.init(this.settings);
});
}
setupEditor(self: GenericRichTextInput, editor: Editor) {
... // details of tinymce API
}
}
When I click the finalize button, The effect of the disabled flag in the rich text component does not change.
Note:
The tinymce library I'm using sets the text area display to none and the aria-hidden to true. This is because it wraps the textarea in a widget. So I have to use the library's api to set disabled.

I figured it out. Ember doesn't run the constructor for the update life-cycle event. So I need to tell Ember to re-run the initializer when the template gets re-rendered. I had to use https://github.com/emberjs/ember-render-modifiers to do this.
So my rich text editor template looks like:
<textarea
id={{this.id}}
{{did-update this.updateDisabled #disabled}}>
{{#value}}
</textarea>
And I added this action in the code behind of the rich text editor:
#action
updateDisabled(element: HTMLTextAreaElement, [disabled]: any[]) {
this.disabled = disabled;
this.editor.destroy();
this.initializeTinymce();
}

Related

Why won't Trix editor mount in Vue component when running tests with Jest?

I built a simple Vue component that wraps the Trix editor. I'm trying to write tests for it, but Trix doesn't seem to mount properly and isn't generating a toolbar element like it does in the browser. I'm using Jest test runner.
TrixEdit.vue
<template>
<div ref="trix">
<trix-editor></trix-editor>
</div>
</template>
<script>
import 'trix'
export default {
mounted() {
let el = this.$refs.trix.getElementsByTagName('trix-editor')[0]
// HACK: change the URL field in the link dialog to allow non-urls
let toolbar = this.$refs.trix.getElementsByTagName('trix-toolbar')[0]
toolbar.querySelector('[type=url]').type = 'text'
// insert content
el.editor.insertHTML(this.value)
el.addEventListener('trix-change', e => {
this.$emit('input', e.target.innerHTML)
})
}
}
</script>
TrixEdit.spec.js
import { mount, shallowMount, createLocalVue } from '#vue/test-utils'
import TrixEdit from '#/components/TrixEdit.vue'
const localVue = createLocalVue()
localVue.config.ignoredElements = ['trix-editor']
describe('TrixEdit', () => {
describe('value prop', () => {
it('renders text when value is set', () => {
const wrapper = mount(TrixEdit, {
localVue,
propsData: {
value: 'This is a test'
}
})
expect(wrapper.emitted().input).toEqual('This is a test')
})
})
})
The expect() fails with the following error
Expected value to equal:
"This is a test"
Received:
undefined
at Object.toEqual (tests/unit/TrixEdit.spec.js:19:39)
Why is Trix not initializing in my test?
trix-editor is not mounting mainly because MutationObserver is not supported in JSDOM 11, and attachToDocument was not used. There were several other bugs in the test described below.
GitHub demo w/issues fixed
Missing MutationObserver and window.getSelection
Vue CLI 3.7.0 uses JSDOM 11, which doesn't support MutationObserver, needed by the Custom Elements polyfill to trigger the connectedCallback. That lifecycle hook normally invokes trix-editor's initialization, which would create the trix-toolbar element that your test is trying to query.
Solution 1: In your test, import mutationobserver-shim before TrixEdit.vue, and stub window.getSelection (called by trix and currently not supported by JSDOM):
import 'mutationobserver-shim' // <-- order important
import TrixEdit from '#/components/TrixEdit.vue'
window.getSelection = () => ({})
Solution 2: Do the above in a Jest setup script, configured by setupTestFrameworkScriptFile:
Add the following property to the config object in jest.config.js (or jest in package.json):
setupTestFrameworkScriptFile: '<rootDir>/jest-setup.js',
Add the following code to <rootDir>/jest-setup.js:
import 'mutationobserver-shim'
window.getSelection = () => ({})
Missing attachToDocument
#vue/test-utils does not attach elements to the document by default, so trix-editor does not catch the connectedCallback, which is needed for its initialization.
Solution: Use the attachToDocument option when mounting TrixEdit:
const wrapper = mount(TrixEdit, {
//...
attachToDocument: true, // <-- needed for trix-editor
})
Premature reference to trix-editor's editor
TrixEdit incorrectly assumes that trix-editor is immediately initialized upon mounting, but initialization isn't guaranteed until it fires the trix-initialize event, so accessing trix-editor's internal editor could result in an undefined reference.
Solution: Add an event handler for the trix-initialize event that invokes the initialization code previously in mounted():
<template>
<trix-editor #trix-initialize="onInit" />
</template>
<script>
export default {
methods: {
onInit(e) {
/* initialization code */
}
}
}
</script>
Value set before change listener
The initialization code adds a trix-change-event listener after the value has already been set, missing the event trigger. I assume the intention was to also detect the first initial value setting in order to re-emit it as an input event.
Solution 1: Add event listener first:
<script>
export default {
methods: {
onInit(e) {
//...
el.addEventListener('trix-change', /*...*/)
/* set editor value */
}
}
}
</script>
Solution 2: Use v-on:trix-change="..." (or #trix-change) in the template, which would remove the setup-order problem above:
<template>
<trix-editor #trix-change="onChange" />
</template>
<script>
export default {
methods: {
onChange(e) {
this.$emit('input', e.target.innerHTML)
},
onInit(e) {
//...
/* set editor value */
}
}
}
</script>
Value setting causes error
The initialization code sets the editor's value with the following code, which causes an error in test:
el.editor.insertHTML(this.value) // causes `document.createRange is not a function`
Solution: Use trix-editor's value-accessor, which performs the equivalent action while avoiding this error:
el.value = this.value
I can confirm the problem lies in the not-so-ideal polymer polyfill included inside trix lib. I did an experiment to force apply the polyfill, then I can reproduce the same error TypeError: Cannot read property 'querySelector' of undefined even inside chrome browser env.
Further investigation narrows it down to the MutationObserver behavior difference, but still haven't got to the bottom.
Way to reproduce:
TrixEdit.vue
<template>
<div ref="trix">
<trix-editor></trix-editor>
</div>
</template>
<script>
// force apply polymer polyfill
delete window.customElements;
document.registerElement = undefined;
import "trix";
//...
</script>

How do I expose state properties from my add on component to the developer?

I'm currently experimenting with creating add-ons in Ember 3.8 and I'm a bit stuck on how to expose state properties to the developer. I have a simple button in my addon:
//ui-button/component.js
import Component from '#ember/component';
import layout from './template';
import { oneWay } from '#ember/object/computed'
export default Component.extend({
layout,
tagName: '',
type: 'button',
task: null,
isRunning: oneWay('task.isRunning'),
disabled: oneWay('task.isRunning'),
onClick(){},
actions: {
click(){
event if 'type' = submit)
event.preventDefault();
let task = this.task;
let onClick = this.onClick;
task ? task.perform() : onClick();
}
}
});
//ui-button/template.hbs
<button onclick={{action "click"}} disabled={{disabled}} type={{type}}>
{{yield}} {{if disabled "..."}}
</button>
The tasks and actions simply live on application controller:
buttonTask: task(function*() {
yield timeout(2000)
yield alert("Clicked after 2 seconds")
}),
actions: {
clicker() {
alert('clicked')
}
}
I can call it from an ember project like this:
<UiButton #onClick={{action "clicker"}}>
Angle Bracket
</UiButton>
<UiButton #task={{task buttonTask}}>
Angle Bracket with task
</UiButton>
and it works fine, but I'd like to be able to give the developer access to disabled or isRunning so that they can add their own behaviour.
I tried to create an intermediate component that yielded out the properties:
//ui-button-yield/template.hbs
{{yield (hash
button=(component "ui-button"
task=#task)
isRunning=task.isRunning
disabled=task.disabled
)
}}
and call it like this:
{{#ui-button-yield as |button|}}
{{#button.button task=buttonTask}}
Handlebars Yield {{if button.isRunning "!!!"}}
{{/button.button}}
{{/ui-button-yield}}
But although the button works I can't access either of the disabled or isRunningproperties from the codeblock. Furthermore, according to Ember Inspector these properties aren't available on the ui-button-yield component, only the ui-button.
your problem is that you yield from ui-button-yield but you've defined isRunning and disabled in ui-button.
If you really want to use this wrapping contextual compoent then you need to move your logic to it.
You got very close but your consuming template needs to be setup a little differently
{{#ui-button-yield as |button isRunning disabled|}}
{{#button.button task=buttonTask}}
Handlebars Yield {{if isRunning "!!!"}}
{{/button.button}}
{{/ui-button-yield}}
You may also want to consider:
{{#ui-button-yield task=buttonTask as |button isRunning disabled|}}
{{#button.button}}
Handlebars Yield {{if isRunning "!!!"}}
{{/button.button}}
{{/ui-button-yield}}

Unit testing user interaction in an angular2 component

I've recently started learning angular2, and I thought I'd try and write a combobox component similar to select2. Here's a simplified version of the component:
#Component({
selector: 'combobox',
template: `
<div class="combobox-wrapper">
<input type="text"></input>
<button class="toggle-button"></button>
</div>
<div *ngIf="isDropdownOpen" class="dropdown">
<div *ngFor="let option of options; let index = index;"
class="option"
[class.focused]="focusedIndex==index">
{{option.label}}
</div>
</div>
`
})
export class ComboboxComponent {
#Input() options: any[] = [];
isDropdownOpen: boolean = false;
focusedIndex: number = 0;
}
I'm struggling when it comes to writing unit tests involving user interaction. For example, I want the user to be able to navigate the list of options by using the up and down keys on the keyboard. The way I see it, I have two options.
Option 1:
describe('when the dropdown is open and the user presses the "UP" key', () => {
it('should focus the first available preceeding option', () => {
const button = fixture.debugElement.query(By.css('.toggle-button'));
const input = fixture.debugElement.query(By.css('input'));
button.triggerEventHandler('mousedown', {});
input.triggerEventHandler('keydown', { key: 'ArrowDown' });
input.triggerEventHandler('keydown', { key: 'ArrowDown' });
input.triggerEventHandler('keydown', { key: 'ArrowUp' });
fixture.detectChanges();
const options = fixture.debugElement.queryAll(By.css('.option'));
expect(options[1].nativeElement.className).toContain('focused');
});
});
Option 2:
describe('when the dropdown is open and the user presses the "UP" key', () => {
it('should focus the first available preceeding option', () => {
const combobox = fixture.componentInstance;
combobox.isDropdownOpen = true;
combobox.focusedIndex = 2;
input.triggerEventHandler('keydown', { key: 'ArrowUp' });
fixture.detectChanges();
expect(combobox.focusedIndex).toBe(1);
});
});
Neither option feels right. In the first case I'm making assumptions about behaviour that is not part of the test itself - namely that clicking the "toggle" button will open the dropdown, and that pressing the "ArrowDown" key will focus the next option on the list.
In the second case I'm accessing properties that are not part of the component's public interface (#Inputs and #Outputs), and the test itself requires detailed knowledge about the actual implementation.
How should I approach this?

Ionic 2 - Get data back from modal

I have a component which is my main interface. Inside this component, clicking a button opens ionic 2 modal which allows to choose items.
My modal page (itemsPage):
..list of items here
<button ion-button [disabled]="!MY_TURN || !selectedItem || !selectedItem.quantity"
(click)="useItem(selectedItem)">
<span>Choose item {{selectedItem?.name}}</span>
</button>
useItem() should:
Send item data to my main interface component
Close the modal
Execute a method in my main interface
How I can perform such actions? Couldn't find any documentation about communicating between modal and component in Ionic 2.
It is simply a matter of using parameters in viewController.
In your main interface component,
let chooseModal = this.modalCtrl.create(itemsPage);
chooseModal.onDidDismiss(data => {
console.log(data);
});
chooseModal.present();
In your modal page,
useItem(item) {
this.viewCtrl.dismiss(item);
}
Modal Controller link here
This is a clear example of getting data from modals in ionic.
You need to add a handler for modal’s onDismiss() and then return the data from the modal itself by passing the data to the ViewController’s dismiss() method:
// myPage.ts
// Passing data to the modal:
let modal = Modal.create(myModal, { data: [...] });
// Getting data from the modal:
modal.onDismiss(data => {
console.log('MODAL DATA', data);
});
this.nav.present(modal);
on the modal page
// myModal.ts
constructor(private navParams: NavParams, private viewCtrl: ViewController) {
// Getting data from the page:
var dataFromPage = navParams.get('data');
}
dismiss() {
// Returning data from the modal:
this.viewCtrl.dismiss(
// Whatever should be returned, e.g. a variable name:
// { name : this.name }
);
}

Ember Data automatic refresh after save

The Ember page I'm working on is to display a of grid of operators and their jobs and have that list automatically update when the user creates a new job. My current best attempt will draw pre-existing jobs on page load, but the page doesn't refresh with any new jobs created using 'saveNewJob' even though I can see the new job in the Data view of Ember Inspector.
Here's the code with some '..snip..' inserted to focus on the important parts:
routes/scheduler.js
export default ember.Route.extend({
model: function() {
return {
jobs: this.store.query('job', {
location: session.location,
date: session.selectedDate
},
operators: this.store.query('operator', {
location: session.location
}
}
},
action: {
saveNewJob: function(params) {
var newJob = this.store.createRecord('job',{
//job properties from params
};
var thisRoute = this;
newJob.save().then(function(){ thisRoute.refresh() });
}
}
}
templates/scheduler.hbs
..snip..
{{#each model.operators as |op|}}
{{operator-row operator=op jobs=model.jobs}}
{{/each}}
{{outlet}}
templates/components/operator-row.hbs
<!-- Draw the grid for the operator -->
..snip..
<!--Draw jobs over grid -->
{{#if jobs.isFulfilled}}
{{#each jobsForOperator as |job|}}
{{operator-job job=job}}
{{/each}}
{{/if}}
component/operator-row.js
jobsForOperator: Ember.computed('jobs', function() {
var opId = this.operator.get('id');
var retVal this.jobs.filter(function(item) {
return item.get('operator').get('id') === opId;
});
<!-- Append some drawing properties to each job in retVal -->
..snip...
},
..snip..
I haven't seen a need to add anything to controller/scheduler. The operator-job component is a simple div that uses the drawing properties to correctly place/draw the div in the operator row.
There are various ways for a new job to be created, but I purposefully left them out because they all end up calling 'saveNewJob' and I am able to get a console.log statement to fire from there.
One solution I've tried is adding a 'this.store.findAll('job');' at the start of the model function and then using 'jobs: this.store.filter('job', function() { })' to create the model.jobs property. That sees neither the existing jobs nor the newly created job returned despite seeing my date and location matches return true.
So what am I doing wrong here? Is there a better way to get this refresh to happen automatically? Appreciate any help you all can give.
There is function for just this case DS.Store.filter. You use it instead of query. Something like this:
let filteredJobs = this.store.filter(
'job',
{location: session.location, date: session.selectedDate},
job => job.get('location') === session.location && job.get('date') === session.selectedDate
);