Skip to content

Commit

Permalink
feat: add header and save/undo controls to forms
Browse files Browse the repository at this point in the history
  • Loading branch information
pheekus committed Jul 16, 2024
1 parent a1cc44c commit 8c974df
Show file tree
Hide file tree
Showing 119 changed files with 4,456 additions and 5,590 deletions.
7,182 changes: 2,753 additions & 4,429 deletions custom-elements.json

Large diffs are not rendered by default.

This file was deleted.

10 changes: 0 additions & 10 deletions src/elements/internal/InternalCreateControl/index.ts

This file was deleted.

91 changes: 72 additions & 19 deletions src/elements/internal/InternalForm/InternalForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ describe('InternalForm', () => {
expect(customElements.get('foxy-internal-timestamps-control')).to.exist;
});

it('imports and registers foxy-internal-create-control', () => {
expect(customElements.get('foxy-internal-create-control')).to.exist;
it('imports and registers foxy-internal-submit-control', () => {
expect(customElements.get('foxy-internal-submit-control')).to.exist;
});

it('imports and registers foxy-internal-undo-control', () => {
expect(customElements.get('foxy-internal-undo-control')).to.exist;
});

it('imports and registers foxy-internal-delete-control', () => {
Expand Down Expand Up @@ -61,35 +65,80 @@ describe('InternalForm', () => {
expect(element.renderHeaderActions(data)).to.equal(null);
});

it('has a .renderHeader() method rendering an optional header', async () => {
it('renders a configurable title in the optional header', async () => {
const root = document.createElement('div');
const element = await fixture<InternalForm<any>>(
html`<foxy-internal-form></foxy-internal-form>`
);

render(element.renderHeader(), root);
let title = root.querySelector(`foxy-i18n[infer="header"][key="${element.headerTitleKey}"]`);

expect(root.querySelector('foxy-i18n[infer="header"][key="title_new"]')).to.exist;
expect(root.querySelector('foxy-i18n[infer="header"][key="title_existing"]')).to.not.exist;
expect(root.querySelector('foxy-i18n[infer="header"][key="subtitle"]')).to.not.exist;
expect(root.querySelector('foxy-copy-to-clipboard')).to.not.exist;
expect(title).to.exist;
expect(title).to.have.deep.property('options', element.headerTitleOptions);

element.data = await getTestData<any>('./hapi/customers/0');
render(element.renderHeader(), root);

expect(root.querySelector('foxy-i18n[infer="header"][key="title_new"]')).to.not.exist;

const title = root.querySelector('foxy-i18n[infer="header"][key="title_existing"]');
title = root.querySelector(`foxy-i18n[infer="header"][key="${element.headerTitleKey}"]`);
expect(title).to.exist;
expect(title).to.have.deep.property('options', { id: 0 });
expect(title).to.have.deep.property('options', element.headerTitleOptions);
});

it('when loaded, renders a configurable subtitle in the optional header', async () => {
const root = document.createElement('div');
const element = await fixture<InternalForm<any>>(
html`<foxy-internal-form></foxy-internal-form>`
);

const subtitle = root.querySelector('foxy-i18n[infer="header"][key="subtitle"]');
render(element.renderHeader(), root);

let subtitle = root.querySelector(
`foxy-i18n[infer="header"][key="${element.headerSubtitleKey}"]`
);

expect(subtitle).to.not.exist;

element.data = await getTestData<any>('./hapi/customers/0');
render(element.renderHeader(), root);

subtitle = root.querySelector(`foxy-i18n[infer="header"][key="${element.headerSubtitleKey}"]`);
expect(subtitle).to.exist;
expect(subtitle).to.have.deep.property('options', element.data);
expect(subtitle).to.have.deep.property('options', element.headerSubtitleOptions);
});

it('when loaded, renders a Copy ID button in the optional header', async () => {
const root = document.createElement('div');
const element = await fixture<InternalForm<any>>(
html`<foxy-internal-form></foxy-internal-form>`
);

render(element.renderHeader(), root);
let copyButton = root.querySelector('foxy-copy-to-clipboard[infer="header copy-id"]');
expect(copyButton).to.not.exist;

element.data = await getTestData<any>('./hapi/customers/0');
render(element.renderHeader(), root);
copyButton = root.querySelector('foxy-copy-to-clipboard[infer="header copy-id"]');
expect(copyButton).to.exist;
expect(copyButton).to.have.attribute('text', String(element.headerCopyIdValue));
});

it('when loaded, renders a Copy JSON button in the optional header', async () => {
const root = document.createElement('div');
const element = await fixture<InternalForm<any>>(
html`<foxy-internal-form></foxy-internal-form>`
);

render(element.renderHeader(), root);
let copyButton = root.querySelector('foxy-copy-to-clipboard[infer="header copy-json"]');
expect(copyButton).to.not.exist;

const copyButton = root.querySelector('foxy-copy-to-clipboard');
element.data = await getTestData<any>('./hapi/customers/0');
render(element.renderHeader(), root);
copyButton = root.querySelector('foxy-copy-to-clipboard[infer="header copy-json"]');
expect(copyButton).to.exist;
expect(copyButton).to.have.property('text', '0');
expect(copyButton).to.have.attribute('text', JSON.stringify(element.data, null, 2));
});

it('has a .renderBody() method rendering timestamps and an appropriate action control', async () => {
Expand All @@ -100,15 +149,19 @@ describe('InternalForm', () => {

render(element.renderBody(), root);

expect(root.querySelector('foxy-internal-create-control[infer="create"]')).to.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="submit"]')).to.not.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="create"]')).to.exist;
expect(root.querySelector('foxy-internal-delete-control[infer="delete"]')).to.not.exist;
expect(root.querySelector('foxy-internal-undo-control[infer="undo"]')).to.not.exist;
expect(root.querySelector('foxy-internal-timestamps-control[infer="timestamps"]')).to.not.exist;

element.data = await getTestData<any>('./hapi/customers/0');
render(element.renderBody(), root);

expect(root.querySelector('foxy-internal-create-control[infer="create"]')).to.not.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="submit"]')).to.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="create"]')).to.not.exist;
expect(root.querySelector('foxy-internal-delete-control[infer="delete"]')).to.exist;
expect(root.querySelector('foxy-internal-undo-control[infer="undo"]')).to.exist;
expect(root.querySelector('foxy-internal-timestamps-control[infer="timestamps"]')).to.exist;
});

Expand All @@ -123,7 +176,7 @@ describe('InternalForm', () => {
await element.requestUpdate();

expect(renderBodyMethod).to.have.been.called;
expect(root.querySelector('foxy-internal-create-control[infer="create"]')).to.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="create"]')).to.exist;
expect(root.querySelector('foxy-internal-delete-control[infer="delete"]')).to.not.exist;
expect(root.querySelector('foxy-internal-timestamps-control[infer="timestamps"]')).to.not.exist;

Expand All @@ -132,7 +185,7 @@ describe('InternalForm', () => {
await element.requestUpdate();

expect(renderBodyMethod).to.have.been.called;
expect(root.querySelector('foxy-internal-create-control[infer="create"]')).to.not.exist;
expect(root.querySelector('foxy-internal-submit-control[infer="create"]')).to.not.exist;
expect(root.querySelector('foxy-internal-delete-control[infer="delete"]')).to.exist;
expect(root.querySelector('foxy-internal-timestamps-control[infer="timestamps"]')).to.exist;

Expand Down
140 changes: 111 additions & 29 deletions src/elements/internal/InternalForm/InternalForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,40 +42,100 @@ export class InternalForm<TData extends HALJSONResource> extends Base<TData> {
return null;
}

/** Getter that returns a i18n key for the optional form header title. */
get headerTitleKey(): string {
return 'title';
}

/** I18next options to pass to the header title translation function. */
get headerTitleOptions(): Record<string, unknown> {
return {
...this.data,
context: this.data ? 'existing' : 'new',
id: this.data ? getResourceId(this.data._links.self.href) : null,
};
}

/** Getter that returns a i18n key for the optional form header subtitle. Note that subtitle is shown only when data is avaiable. */
get headerSubtitleKey(): string {
return 'subtitle';
}

/** I18next options to pass to the header subtitle translation function. Note that subtitle is shown only when data is avaiable. */
get headerSubtitleOptions(): Record<string, unknown> {
return this.data ?? {};
}

/** ID that will be written to clipboard when Copy ID button in header is clicked. */
get headerCopyIdValue(): string | number {
return this.data ? getResourceId(this.data._links.self.href) ?? '' : '';
}

/**
* Renders optional form header with ID, last update timestamp and actions list (snapshot-only).
* Customize which actions are rendered with `.renderHeaderActions()` method.
* Renders optional form header.
* - Customize which actions are rendered with `.renderHeaderActions()` method.
* - Customize the header title and subtitle with `.headerTitleKey` and `.headerSubtitleKey` getters.
* - Customize the header title and subtitle options with `.headerTitleOptions` and `.headerSubtitleOptions` getters.
* - To hide the header completely, add `header` to `hidden-controls` attribute.
*/
renderHeader(): TemplateResult {
if (this.hiddenSelector.matches('header', true)) return html``;

const data = this.data;
const actions = data ? this.renderHeaderActions(data) : null;
const id = data ? getResourceId(data._links.self.href) : '';

return html`
<h2>
<span class="flex items-center gap-s leading-xs text-xxl font-medium break-all">
<foxy-i18n infer="header" key=${data ? 'title_existing' : 'title_new'} .options=${{ id }}>
</foxy-i18n>
<div>
${this.renderTemplateOrSlot('header:before')}
<h2>
<span class="flex items-center gap-s leading-xs text-xl font-medium break-all">
<foxy-i18n
options=${JSON.stringify(this.headerTitleOptions)}
infer="header"
key=${this.headerTitleKey}
>
</foxy-i18n>
${data
? html`
${this.hiddenSelector.matches('header:copy-id', true)
? ''
: html`
<foxy-copy-to-clipboard
infer="header copy-id"
class="text-m"
text=${this.headerCopyIdValue}
>
</foxy-copy-to-clipboard>
`}
${this.hiddenSelector.matches('header:copy-json', true)
? ''
: html`
<foxy-copy-to-clipboard
infer="header copy-json"
class="text-m"
icon="icons:code"
text=${JSON.stringify(data, null, 2)}
>
</foxy-copy-to-clipboard>
`}
`
: ''}
</span>
${data
? html`
<foxy-copy-to-clipboard infer="header" class="text-m" text=${id}>
</foxy-copy-to-clipboard>
<foxy-i18n
infer="header"
class="text-m text-secondary"
key=${this.headerSubtitleKey}
.options=${this.headerSubtitleOptions}
>
</foxy-i18n>
${actions ? html`<div class="mt-xs flex gap-m">${actions}</div>` : ''}
`
: ''}
</span>
${data
? html`
<foxy-i18n
infer="header"
class="text-l text-secondary"
key="subtitle"
.options=${data}
>
</foxy-i18n>
${actions ? html`<div class="mt-xs flex gap-m">${actions}</div>` : ''}
`
: ''}
</h2>
</h2>
${this.renderTemplateOrSlot('header:after')}
</div>
`;
}

Expand All @@ -85,12 +145,34 @@ export class InternalForm<TData extends HALJSONResource> extends Base<TData> {
* don't forget to add `super.renderBody()` to your template.
*/
renderBody(): TemplateResult {
return this.data
? html`
<foxy-internal-timestamps-control infer="timestamps"></foxy-internal-timestamps-control>
<foxy-internal-delete-control infer="delete"></foxy-internal-delete-control>
`
: html`<foxy-internal-create-control infer="create"></foxy-internal-create-control>`;
if (this.data) {
const isSnapshotDirty = this.in({ idle: { snapshot: 'dirty' } });
const isDeleteHidden = this.hiddenSelector.matches('delete', true);
const actionClass = classMap({ 'transition-opacity': true, 'opacity-0': !isSnapshotDirty });

return html`
<foxy-internal-timestamps-control infer="timestamps"></foxy-internal-timestamps-control>
${!isDeleteHidden || isSnapshotDirty
? html`
<div class="flex gap-s">
<foxy-internal-delete-control infer="delete"></foxy-internal-delete-control>
<div class="w-full"></div>
<foxy-internal-undo-control class=${actionClass} infer="undo">
</foxy-internal-undo-control>
<foxy-internal-submit-control class=${actionClass} infer="submit">
</foxy-internal-submit-control>
</div>
`
: ''}
`;
} else {
return html`
<div class="flex">
<foxy-internal-submit-control infer="create" theme="primary success" class="ml-auto">
</foxy-internal-submit-control>
</div>
`;
}
}

/**
Expand Down
Loading

0 comments on commit 8c974df

Please sign in to comment.