Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Option } from './types';

import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl';
import { ifDefined } from 'lit-html/directives/if-defined';
import { live } from 'lit-html/directives/live';
import { html, svg } from 'lit-html';
import { classMap } from '../../../utils/class-map';

Expand Down Expand Up @@ -107,6 +108,7 @@ export class InternalSelectControl extends InternalEditableControl {
private __renderSummaryItemLayout() {
const selection = this.options.find(v => v.value === this._value);

// `live(.selected)`, not `?selected`: the attribute is ignored once the user has picked.
return html`
<div class="leading-xs">
<div class="flex items-center gap-s">
Expand Down Expand Up @@ -156,7 +158,7 @@ export class InternalSelectControl extends InternalEditableControl {
html`
<option
value=${option.value}
?selected=${selection && option.value === this._value}
.selected=${live(!!selection && option.value === this._value)}
>
${'label' in option ? this.t(option.label) : option.rawLabel}
</option>
Expand Down
68 changes: 51 additions & 17 deletions src/elements/public/UserInvitationForm/UserInvitationForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,26 @@ describe('UserInvitationForm', () => {
expect(action).to.have.attribute('theme', 'error');
});

it('renders revoke after the scope control and outside the resend/delete row', async () => {
const router = createRouter();
const form = await fixture<Form>(html`
<foxy-user-invitation-form
layout="admin"
href="https://demo.api/hapi/user_invitations/0"
@fetch=${(evt: FetchEvent) => router.handleEvent(evt)}
>
</foxy-user-invitation-form>
`);

await waitUntil(() => !!form.data, undefined, { timeout: 5000 });
const scope = form.renderRoot.querySelector('foxy-internal-user-invitation-form-scope-control');
const revoke = form.renderRoot.querySelector('[infer="revoke"]')!;
const resend = form.renderRoot.querySelector('[infer="resend"]')!;

expect(scope!.compareDocumentPosition(revoke) & Node.DOCUMENT_POSITION_FOLLOWING).to.be.ok;
expect(resend.parentElement!.contains(revoke)).to.be.false;
});

it("renders a special async action for revoking current user's access in snapshot admin layout", async () => {
const router = createRouter();
const form = await fixture<Form>(html`
Expand Down Expand Up @@ -751,6 +771,30 @@ describe('UserInvitationForm', () => {
expect(element.hiddenSelector.matches('scope', true)).to.be.false;
});

it('shows the scope error itself when Custom is the only option', async () => {
const element = await form('customers_read customers_write');
element.edit({ email: 'test@example.com' });
await element.requestUpdate();

const control = element.renderRoot.querySelector(
'foxy-internal-user-invitation-form-scope-control[infer="scope"]'
) as HTMLElement & {
reportValidity(): void;
t(key: string): string;
updateComplete: Promise<unknown>;
};

await control.updateComplete;
expect(control.shadowRoot?.querySelector('[data-preset]')).to.not.exist;
expect(control.shadowRoot?.querySelector('p.text-error')).to.not.exist;

control.reportValidity();
await control.updateComplete;

const error = control.shadowRoot?.querySelector('p.text-error');
expect(error?.textContent?.trim()).to.equal(control.t('v8n_required'));
});

it('renders the scope control in the admin template state', async () => {
const element = await form('store_full_access');
const control = element.renderRoot.querySelector(
Expand Down Expand Up @@ -992,30 +1036,17 @@ describe('UserInvitationForm', () => {
// waiting for its own `updateComplete` too.
await (control as unknown as { updateComplete: Promise<unknown> })?.updateComplete;

const presetControl = control?.shadowRoot?.querySelector(
'foxy-internal-select-control[data-preset]'
) as
| (HTMLElement & { readonly: boolean; updateComplete: Promise<unknown> })
| null
| undefined;

expect(presetControl, 'the preset control must exist for this check to be meaningful').to
.exist;

// Same reason as the `control` wait above: the preset control's own `readonly`
// (inferred from `control`, in turn inferred from the form) settles on its own
// microtask.
await presetControl?.updateComplete;
expect(presetControl?.readonly).to.be.false;

// And prove it's actually editable, not just unlocked in appearance: change a level
// through the row's `foxy-internal-select-control` (`data-collection` lives directly on
// it -- there's no separate row wrapper since the summary-item rework) and confirm it
// lands in `form.scope`. `customers` currently holds `full` (via `customers_write`) and
// this granter can re-grant it at any level, so drop it to `read`.
const rowControl = control?.shadowRoot?.querySelector(
'foxy-internal-select-control[data-collection="customers"]'
) as (HTMLElement & { updateComplete: Promise<unknown> }) | null | undefined;
) as
| (HTMLElement & { readonly: boolean; updateComplete: Promise<unknown> })
| null
| undefined;

expect(rowControl, 'the customers row control must exist for this edit to be meaningful').to
.exist;
Expand All @@ -1024,6 +1055,9 @@ describe('UserInvitationForm', () => {
// `readonly` (inferred from `control`, in turn inferred from the form) settles on its
// own microtask.
await rowControl?.updateComplete;
// Custom is this granter's only option, so the preset select is not rendered; the row's
// own `readonly` is what must be false.
expect(rowControl?.readonly).to.be.false;

const select = rowControl?.shadowRoot?.querySelector('select') as
| (HTMLElement & { value: string })
Expand Down
61 changes: 32 additions & 29 deletions src/elements/public/UserInvitationForm/UserInvitationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,35 +375,8 @@ export class UserInvitationForm extends Base<Data> {

<div
class="flex gap-m"
?hidden=${hidden.matches('revoke', true) &&
hidden.matches('resend', true) &&
hidden.matches('delete', true)}
?hidden=${hidden.matches('resend', true) && hidden.matches('delete', true)}
>
${this.__isOwnInvitationForCurrentStore
? html`
<foxy-internal-post-action-control
message-options=${JSON.stringify({
store_domain: this.__storeDomainGetValue(),
store_name,
})}
infer="revoke"
class="flex-1"
theme="error"
href=${ifDefined(this.data?._links['fx:revoke']?.href)}
@success=${() => this.dispatchEvent(new CustomEvent('selfrevoked'))}
>
</foxy-internal-post-action-control>
`
: html`
<foxy-internal-user-invitation-form-async-action
infer="revoke"
class="flex-1"
theme="error"
href=${ifDefined(this.data?._links['fx:revoke']?.href)}
>
</foxy-internal-user-invitation-form-async-action>
`}

<foxy-internal-user-invitation-form-async-action
infer="resend"
class="flex-1"
Expand All @@ -423,7 +396,37 @@ export class UserInvitationForm extends Base<Data> {
>
</foxy-internal-user-invitation-form-scope-control>

<foxy-internal-submit-control infer="submit"></foxy-internal-submit-control>
<div
class="flex items-center gap-m"
?hidden=${hidden.matches('revoke', true) && hidden.matches('submit', true)}
>
${this.__isOwnInvitationForCurrentStore
? html`
<foxy-internal-post-action-control
message-options=${JSON.stringify({
store_domain: this.__storeDomainGetValue(),
store_name,
})}
infer="revoke"
theme="error"
href=${ifDefined(this.data?._links['fx:revoke']?.href)}
@success=${() => this.dispatchEvent(new CustomEvent('selfrevoked'))}
>
</foxy-internal-post-action-control>
`
: html`
<foxy-internal-user-invitation-form-async-action
infer="revoke"
theme="error"
href=${ifDefined(this.data?._links['fx:revoke']?.href)}
>
</foxy-internal-user-invitation-form-async-action>
`}
<foxy-internal-submit-control
class="ml-auto"
infer="submit"
></foxy-internal-submit-control>
</div>
</div>
`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import './index';
import { expect, fixture, html } from '@open-wc/testing';
import { COLLECTIONS, RESENDABLE } from '../../../../../utils/group-invitation-scopes';
import { InternalEditableControl } from '../../../../internal/InternalEditableControl/InternalEditableControl';
import { ROLES, serializeScope, parseScope } from '../../../../../utils/invitation-scope';
import { spy } from 'sinon';

const allReads = COLLECTIONS.map(collection => `${collection}_read`).join(' ');
Expand Down Expand Up @@ -54,15 +55,87 @@ describe('InternalUserInvitationFormScopeControl', () => {
});

it('offers every preset to a store_full_access granter', async () => {
expect(presets(await control('store_full_access'))).to.deep.equal(['store', 'read', 'custom']);
expect(presets(await control('store_full_access'))).to.deep.equal([
'store',
'read',
'customer_service',
'developer',
'designer',
'accountant',
'custom',
]);
});

it('omits the store preset when the granter lacks store_full_access', async () => {
expect(presets(await control(allReads))).to.deep.equal(['read', 'custom']);
expect(presets(await control(allReads))).to.deep.equal(['read', 'accountant', 'custom']);
});

it('omits the read preset when the granter cannot grant all reads', async () => {
expect(presets(await control('customers_read'))).to.deep.equal(['custom']);
it('applies Custom and hides the preset select when it is the only option', async () => {
const element = await control('customers_read');
expect(presetControl(element)).to.not.exist;
expect(element.renderRoot.querySelector('.matrix')).to.exist;
});

it('offers only the roles the granter can fully grant', async () => {
const granter = ROLES.designer.replace('templates_write', 'templates_read');

expect(presets(await control(ROLES.designer))).to.deep.equal(['designer', 'custom']);
expect(presetControl(await control(granter))).to.not.exist;
});

it('replaces the selection with a role, shows its name and keeps extra tokens', async () => {
const element = await control('store_full_access', 'customers_read client_full_access');
let written: unknown = 'customers_read client_full_access';
element.getValue = () => written;
element.setValue = value => (written = value);

selectPreset(element, 'designer');
await element.requestUpdate();
await element.updateComplete;
await presetControl(element)?.updateComplete;

const expected = serializeScope({
...parseScope(ROLES.designer),
preset: 'custom',
extra: ['client_full_access'],
});

expect(written).to.equal(expected);
expect(presetSelect(element)?.value).to.equal('designer');
expect(element.renderRoot.querySelector('.matrix')).to.exist;

setLevel(element, 'templates', 'read');
await element.requestUpdate();
await element.updateComplete;
await presetControl(element)?.updateComplete;

expect(presetSelect(element)?.value).to.equal('custom');
});

it('shows Custom access when Custom is picked while the scope matches a role', async () => {
const element = await control('store_full_access', ROLES.accountant);
let written: unknown = ROLES.accountant;
element.getValue = () => written;
element.setValue = value => (written = value);
await element.requestUpdate();
await presetControl(element)?.updateComplete;
expect(presetSelect(element)?.value).to.equal('accountant');

selectPreset(element, 'custom');
await element.requestUpdate();
await element.updateComplete;
await presetControl(element)?.updateComplete;

expect(written).to.equal(ROLES.accountant);
expect(presetSelect(element)?.value).to.equal('custom');
});

it('shows the role whose scope an existing invitation matches', async () => {
const element = await control('templates_read', `${ROLES.designer} client_full_access`);
await presetControl(element)?.updateComplete;

expect(presets(element)).to.include('designer');
expect(presetSelect(element)?.value).to.equal('designer');
});

it('selects no preset for an empty scope', async () => {
Expand Down Expand Up @@ -272,7 +345,12 @@ describe('custom matrix', () => {

expect(levelsFor(element, 'customers')).to.deep.equal(['none', 'read']);
expect(levelsFor(element, 'carts')).to.deep.equal(['none', 'read', 'full']);
expect(levelsFor(element, 'coupons')).to.deep.equal(['none']);
expect(levelControl(element, 'coupons')).to.not.exist;
});

it('still shows an ungrantable row the invitation holds', async () => {
const element = await control('customers_read', 'customers_read coupons_read');
expect(levelControl(element, 'coupons')).to.exist;
});

it('writes both tokens when a row is set to full', async () => {
Expand Down Expand Up @@ -598,8 +676,8 @@ describe('row layout at narrow widths', () => {
describe('locking on an unreachable grant', () => {
it('makes the whole control readonly when the invitation holds a level the granter cannot re-grant', async () => {
const element = await control('customers_read', 'customers_write');
await presetControl(element)?.updateComplete;
expect(presetControl(element)?.readonly).to.be.true;
await levelControl(element, 'customers')?.updateComplete;
expect(levelControl(element, 'customers')?.readonly).to.be.true;
});

it('still renders the out-of-reach level as the row value, hidden from interaction, while locked', async () => {
Expand All @@ -621,15 +699,15 @@ describe('locking on an unreachable grant', () => {

it('locks on a resend grant the granter cannot re-extend', async () => {
const element = await control('transactions_read', 'transactions_read transactions_resend');
await presetControl(element)?.updateComplete;
expect(presetControl(element)?.readonly).to.be.true;
await levelControl(element, 'transactions')?.updateComplete;
expect(levelControl(element, 'transactions')?.readonly).to.be.true;
});

it('shows the locked helper text only while locked', async () => {
const locked = await control('customers_read', 'customers_write');
const unlocked = await control('store_full_access', 'customers_read');

const lockedSummary = presetControl(locked)?.closest('foxy-internal-summary-control');
const lockedSummary = locked.renderRoot.querySelector('foxy-internal-summary-control');
const unlockedSummary = presetControl(unlocked)?.closest('foxy-internal-summary-control');

expect((lockedSummary as InternalEditableControl | null)?.helperText).to.equal(
Expand Down
Loading
Loading