Skip to content

Commit

Permalink
feat: merge pull request #46 from Foxy/feat/foxy-user-form
Browse files Browse the repository at this point in the history
feat: add user elements
  • Loading branch information
pheekus authored Jul 6, 2021
2 parents 99c3b04 + beb8c53 commit 43e7f84
Show file tree
Hide file tree
Showing 18 changed files with 917 additions and 50 deletions.
68 changes: 22 additions & 46 deletions src/elements/private/Checkbox/Checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ import {
} from 'lit-element';

import { CheckboxChangeEvent } from './CheckboxChangeEvent';
import { CheckboxMachine } from './CheckboxMachine';
import { Themeable } from '../../../mixins/themeable';
import { interpret } from 'xstate';
import { ConfigurableMixin } from '../../../mixins/configurable';
import { ThemeableMixin } from '../../../mixins/themeable';

export class Checkbox extends LitElement {
public static get styles(): CSSResultArray {
export class Checkbox extends ConfigurableMixin(ThemeableMixin(LitElement)) {
static get properties(): PropertyDeclarations {
return {
...super.properties,
checked: { type: Boolean },
};
}

static get styles(): CSSResultArray {
return [
Themeable.styles,
super.styles,
css`
.ml-xxl {
margin-left: calc(var(--lumo-space-m) + 1.125rem);
Expand All @@ -32,46 +38,11 @@ export class Checkbox extends LitElement {
];
}

public static get properties(): PropertyDeclarations {
return {
...super.properties,
checked: { type: Boolean, noAccessor: true },
disabled: { type: Boolean, noAccessor: true },
};
}

private readonly __machine = CheckboxMachine.withConfig({
actions: {
sendChange: () => {
this.dispatchEvent(new CheckboxChangeEvent(this.checked));
},
},
});
checked = false;

private readonly __service = interpret(this.__machine)
.onTransition(state => state.changed && this.requestUpdate())
.start();

public get checked(): boolean {
return this.__service.state.matches('checked');
}

public set checked(value: boolean) {
if (value !== this.checked) this.__service.send('FORCE_TOGGLE');
}

public get disabled(): boolean {
const states = ['checked.disabled', 'unchecked.disabled'];
return states.some(state => this.__service.state.matches(state));
}

public set disabled(value: boolean) {
this.__service.send(value ? 'DISABLE' : 'ENABLE');
}

public render(): TemplateResult {
render(): TemplateResult {
const checked = this.checked;
const ease = 'transition ease-in-out duration-200';
const ease = 'transition-colors ease-in-out duration-200';
const box = `${ease} ${checked ? 'bg-primary' : 'bg-contrast-20 group-hover-bg-contrast-30'}`;
const dot = `${ease} transform ${checked ? 'scale-100' : 'scale-0'}`;

Expand All @@ -87,10 +58,15 @@ export class Checkbox extends LitElement {
.checked=${checked}
?disabled=${this.disabled}
data-testid="input"
@change=${(evt: Event) => [evt.stopPropagation(), this.__service.send('TOGGLE')]}
@change=${(evt: Event) => {
evt.stopPropagation();
this.checked = !this.checked;
this.dispatchEvent(new CheckboxChangeEvent(this.checked));
}}
/>
</div>
<div class="font-lumo text-body leading-m -mt-xs ml-m">
<div class="flex-1 font-lumo text-body leading-m -mt-xs ml-m">
<slot></slot>
</div>
</label>
Expand Down
25 changes: 25 additions & 0 deletions src/elements/public/UserForm/UserForm.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import './index';

import { Summary } from '../../../storygen/Summary';
import { getMeta } from '../../../storygen/getMeta';
import { getStory } from '../../../storygen/getStory';

const summary: Summary = {
href: 'https://demo.foxycart.com/s/admin/users/0',
parent: 'https://demo.foxycart.com/s/admin/stores/0/users',
nucleon: true,
localName: 'foxy-user-form',
translatable: true,
configurable: {},
};

export default getMeta(summary);

export const Playground = getStory({ ...summary, code: true });
export const Empty = getStory(summary);
export const Error = getStory(summary);
export const Busy = getStory(summary);

Empty.args.href = '';
Error.args.href = 'https://demo.foxycart.com/s/admin/not-found';
Busy.args.href = 'https://demo.foxycart.com/s/admin/sleep';
176 changes: 176 additions & 0 deletions src/elements/public/UserForm/UserForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import './index';

import * as sinon from 'sinon';

import { elementUpdated, expect, fixture, html, waitUntil } from '@open-wc/testing';

import { ButtonElement } from '@vaadin/vaadin-button';
import { DialogHideEvent } from '../../private/Dialog/DialogHideEvent';
import { FetchEvent } from '../NucleonElement/FetchEvent';
import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog';
import { UserForm } from './UserForm';
import { router } from '../../../server/admin/index';

const a51 = Array(52).join('a');
const a101 = Array(102).join('a');

describe('UserForm', () => {
describe('creating a new user', () => {
const cases: any = [
{
case: 'should create new users when sufficient valid data is provided',
data: {
first_name: 'John',
last_name: 'Doe',
email: '[email protected]',
phone: '55555555',
},
method: 'submit',
expectation: 'once',
},
{
case: 'should not create new users when insufficient data is provided',
data: {
first_name: 'John',
last_name: 'Doe',
phone: '55555555',
},
method: 'submit',
expectation: 'never',
},
];

for (const d of Object.keys(cases[0].data)) {
const newCase = { ...cases[0] };
newCase.data = { ...cases[0].data };
newCase.data[d] = a101;
newCase.expectation = 'never';
newCase.case = 'should not create new user with invalid ' + d;
cases.push(newCase);
}

for (const c of cases) {
it(c.case, async () => {
const el = await fixture<UserForm>(html`
<foxy-user-form @fetch=${(evt: FetchEvent) => router.handleEvent(evt)}></foxy-user-form>
`);

const mockEl = sinon.mock(el);
(mockEl.expects(c.method) as any)[c.expectation]();
const button = el.shadowRoot?.querySelector<ButtonElement>('[data-testid="action"]');
const inputEl = el.shadowRoot?.querySelector(`[data-testid="phone"]`);

el.edit(c.data);
await elementUpdated(inputEl as HTMLInputElement);
expect(button).to.exist;

button!.click();
await waitUntil(() => el.in('idle'), 'Element should become idle');
mockEl.verify();
});
}
});

describe('input validation', () => {
const cases = [
{ name: 'first_name', message: 'v8n_too_long', value: a51 },
{ name: 'last_name', message: 'v8n_too_long', value: a51 },
{ name: 'email', message: 'v8n_too_long', value: a101 },
{ name: 'email', message: 'v8n_invalid_email', value: 'not an email' },
{ name: 'email', message: 'v8n_required', value: '' },
{ name: 'phone', message: 'v8n_too_long', value: a51 },
];

for (const c of cases) {
it('Validates ' + c.name, async () => {
const el: UserForm = await fixture(html`
<foxy-user-form @fetch=${(evt: FetchEvent) => router.handleEvent(evt)}></foxy-user-form>
`);

await waitUntil(() => el.in('idle'), 'Element should become idle');

const changes: any = {};
changes[c.name] = c.value;
el.edit(changes);

const inputEl = el.shadowRoot?.querySelector(`[data-testid="${c.name}"]`);
expect(inputEl).to.exist;

await elementUpdated(inputEl as HTMLInputElement);
const error = inputEl?.getAttribute('error-message');
expect(error).to.equal(c.message);
});
}
});

describe('deleting a user', () => {
let el: UserForm;
let mockEl: sinon.SinonMock;

beforeEach(async () => {
el = await fixture(html`
<foxy-user-form
href="https://demo.foxycart.com/s/admin/error_entries/0"
@fetch=${(evt: FetchEvent) => router.handleEvent(evt)}
></foxy-user-form>
`);
await waitUntil(() => el.in('idle'), 'Element should become idle');
mockEl = sinon.mock(el);
});

it('should not delete before confirmation', async () => {
mockEl.expects('delete').never();
const button = el.shadowRoot!.querySelector('[data-testid="action"]') as ButtonElement;
button!.click();
mockEl.verify();
});

it('should not delete after cancelation', async () => {
mockEl.expects('delete').never();
const button = el.shadowRoot!.querySelector('[data-testid="action"]') as ButtonElement;
button!.click();
const confirmDialog = el.shadowRoot!.querySelector(
'[data-testid="confirm"]'
) as InternalConfirmDialog;
await elementUpdated(el);
confirmDialog.dispatchEvent(new DialogHideEvent(true));
await elementUpdated(el);
mockEl.verify();
});

it('should delete after confirmation', async () => {
mockEl.expects('delete').once();
const button = el.shadowRoot!.querySelector('[data-testid="action"]') as ButtonElement;
button!.click();
const confirmDialog = el.shadowRoot!.querySelector(
'[data-testid="confirm"]'
) as InternalConfirmDialog;
await elementUpdated(el);
confirmDialog.dispatchEvent(new DialogHideEvent());
await elementUpdated(el);
mockEl.verify();
});
});

it('should provide user feedback while loading', async () => {
const el: UserForm = await fixture(html`
<foxy-user-form
href="https://demo.foxycart.com/s/admin/sleep"
@fetch=${(evt: FetchEvent) => router.handleEvent(evt)}
>
</foxy-user-form>
`);

expect(el.shadowRoot?.querySelector(`[data-testid="spinner"]`)).not.to.have.class('opacity-0');

el.href = ' https://demo.foxycart.com/s/admin/not-found';
await elementUpdated(el);
expect(el.shadowRoot?.querySelector(`[data-testid="spinner"]`)).not.to.have.class('opacity-0');

el.href = 'https://demo.foxycart.com/s/admin/users/0';
expect(el.shadowRoot?.querySelector(`[data-testid="spinner"]`)).not.to.have.class('opacity-0');

await waitUntil(() => el.in('idle'), 'Element should become idle');
expect(el.shadowRoot?.querySelector(`[data-testid="spinner"]`)).to.have.class('opacity-0');
});
});
Loading

0 comments on commit 43e7f84

Please sign in to comment.