Skip to content
Merged
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
175 changes: 175 additions & 0 deletions packages/showcase/src/stories/AccessPicker.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import {
AccessPickerComponent,
type AccessOrg,
type AccessPreset,
type AccessProduct,
type AccessSelection,
} from '@ferrlabs/ui-ng';
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { expect, userEvent, waitFor, within } from 'storybook/test';

const orgs: readonly AccessOrg[] = [
{ id: 'org-1', name: 'Acme', slug: 'acme' },
{ id: 'org-2', name: 'Globex', slug: 'globex' },
];

const catalogue: readonly AccessProduct[] = [
{
product: 'flow',
label: 'FerrFlow',
scopes: [
{ scope: 'flow:read', access: 'read' },
{ scope: 'flow:write', access: 'write' },
{ scope: 'flow:admin', access: 'admin' },
],
},
{
product: 'vault',
label: 'FerrVault',
scopes: [
{ scope: 'vault:read', access: 'read' },
{ scope: 'vault:write', access: 'write' },
],
},
];

const presets: readonly AccessPreset[] = [
{ id: 'read_only', scopes: ['flow:read', 'vault:read'] },
{ id: 'read_write', scopes: ['flow:read', 'flow:write', 'vault:read', 'vault:write'] },
{
id: 'full',
scopes: ['flow:read', 'flow:write', 'flow:admin', 'vault:read', 'vault:write'],
},
];

const meta: Meta<AccessPickerComponent> = {
title: 'Forms/Access picker',
component: AccessPickerComponent,
decorators: [moduleMetadata({ imports: [AccessPickerComponent] })],
args: { orgs, catalogue, presets, initialScopes: [] },
render: (args) => {
const output = { text: 'pending' };
return {
props: {
...args,
output,
report: (value: AccessSelection | null) => (output.text = JSON.stringify(value)),
},
template: `<flr-access-picker
[orgs]="orgs"
[catalogue]="catalogue"
[presets]="presets"
[initialScopes]="initialScopes"
(selectionChange)="report($event)"
/>
<pre data-testid="selection">{{ output.text }}</pre>`,
};
},
};

export default meta;
type Story = StoryObj<AccessPickerComponent>;

async function expectLast(
canvasElement: HTMLElement,
expected: AccessSelection | null,
): Promise<void> {
await waitFor(() =>
expect(within(canvasElement).getByTestId('selection')).toHaveTextContent(
JSON.stringify(expected),
),
);
}

export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectLast(canvasElement, {
orgIds: null,
scopes: ['flow:read', 'vault:read'],
});
await waitFor(() =>
expect(canvas.getByRole('button', { name: 'Read only' })).toHaveAttribute(
'aria-pressed',
'true',
),
);
},
};

export const InitialScopes: Story = {
args: { initialScopes: ['flow:write', 'unknown:scope'] },
play: async ({ canvasElement }) => {
await expectLast(canvasElement, {
orgIds: null,
scopes: ['flow:write'],
});
},
};

export const OnlyUnknownInitialScopesFallBackToReadOnly: Story = {
args: { initialScopes: ['unknown:scope'] },
play: async ({ canvasElement }) => {
await expectLast(canvasElement, {
orgIds: null,
scopes: ['flow:read', 'vault:read'],
});
},
};

export const ChosenOrgsNeedAtLeastOne: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('radio', { name: 'Only these organizations' }));
await expectLast(canvasElement, null);
await expect(await canvas.findByText('Pick at least one organization.')).toBeVisible();

await userEvent.click(canvas.getByRole('checkbox', { name: /Globex/ }));
await expectLast(canvasElement, {
orgIds: ['org-2'],
scopes: ['flow:read', 'vault:read'],
});

await userEvent.click(canvas.getByRole('radio', { name: /All my organizations/ }));
await expectLast(canvasElement, {
orgIds: null,
scopes: ['flow:read', 'vault:read'],
});
},
};

export const PresetsAndManualToggles: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pressed = (name: string, value: string) =>
waitFor(() =>
expect(canvas.getByRole('button', { name })).toHaveAttribute('aria-pressed', value),
);

await userEvent.click(canvas.getByRole('button', { name: 'Full access' }));
await expectLast(canvasElement, {
orgIds: null,
scopes: ['flow:admin', 'flow:read', 'flow:write', 'vault:read', 'vault:write'],
});
await pressed('Full access', 'true');

await userEvent.click(canvas.getByRole('checkbox', { name: 'flow:admin' }));
await pressed('Read and write', 'true');

await userEvent.click(canvas.getByRole('checkbox', { name: 'vault:write' }));
for (const name of ['Read only', 'Read and write', 'Full access']) {
await pressed(name, 'false');
}
},
};

export const NoScopeEmitsNull: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('checkbox', { name: 'flow:read' }));
await userEvent.click(canvas.getByRole('checkbox', { name: 'vault:read' }));
await expectLast(canvasElement, null);
await expect(await canvas.findByText('Pick at least one permission.')).toBeVisible();
},
};
137 changes: 137 additions & 0 deletions packages/ui-ng/src/lib/access-picker/access-picker.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
:host {
display: block;
}

.flr-ap__group {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}

.flr-ap__group + .flr-ap__group {
margin-top: 20px;
}

.flr-ap__legend {
padding: 0;
color: var(--color-ink, #1e293b);
font-size: 14px;
font-weight: 600;
}

.flr-ap__row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 8px;
color: var(--color-ink-2, #334155);
font-size: 14px;
cursor: pointer;
}

.flr-ap__row--center {
align-items: center;
margin-top: 0;
}

.flr-ap__row input {
accent-color: var(--color-accent, #1e293b);
}

.flr-ap__row input[type='radio'] {
margin-top: 4px;
}

.flr-ap__row input:focus-visible {
outline: 2px solid var(--color-accent, #1e293b);
outline-offset: 2px;
}

.flr-ap__orgs {
display: grid;
gap: 4px;
margin: 8px 0 0 24px;
padding: 0;
list-style: none;
}

.flr-ap__slug,
.flr-ap__scope {
font-family: var(--font-mono, 'DM Mono', ui-monospace, monospace);
}

.flr-ap__slug {
color: var(--color-ink-3, #64748b);
font-size: 12px;
}

.flr-ap__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}

.flr-ap__preset {
padding: 4px 12px;
border: 1px solid var(--color-rule-strong, rgba(30, 41, 59, 0.28));
border-radius: 999px;
background: transparent;
color: var(--color-ink-2, #334155);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}

.flr-ap__preset:focus-visible {
outline: 2px solid var(--color-accent, #1e293b);
outline-offset: 2px;
}

.flr-ap__preset--on {
border-color: var(--color-accent, #1e293b);
background: var(--color-accent, #1e293b);
color: var(--color-on-accent, #ffffff);
}

.flr-ap__products {
display: grid;
gap: 12px;
margin-top: 12px;
}

.flr-ap__product {
margin: 0;
color: var(--color-ink-3, #64748b);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}

.flr-ap__scopes {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 4px;
}

.flr-ap__scope {
font-size: 14px;
}

.flr-ap__scope--admin {
color: var(--color-warning-fg, #78350f);
}

.flr-ap__warn {
margin: 8px 0 0;
color: var(--color-warning-fg, #78350f);
font-size: 12px;
}

.flr-ap__warn--indented {
margin-left: 24px;
}
72 changes: 72 additions & 0 deletions packages/ui-ng/src/lib/access-picker/access-picker.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<fieldset class="flr-ap__group">
<legend class="flr-ap__legend">{{ labels().orgsTitle }}</legend>
<label class="flr-ap__row">
<input type="radio" [name]="groupName" [checked]="allOrgs()" (change)="useAllOrgs()" />
<span>{{ labels().allOrgs }}</span>
</label>
<label class="flr-ap__row">
<input type="radio" [name]="groupName" [checked]="!allOrgs()" (change)="useChosenOrgs()" />
<span>{{ labels().someOrgs }}</span>
</label>
@if (!allOrgs()) {
<ul class="flr-ap__orgs">
@for (org of orgs(); track org.id) {
<li>
<label class="flr-ap__row flr-ap__row--center">
<input
type="checkbox"
[checked]="pickedOrgs().has(org.id)"
(change)="toggleOrg(org.id)"
/>
<span>{{ org.name }}</span>
<span class="flr-ap__slug">{{ org.slug }}</span>
</label>
</li>
}
</ul>
@if (pickedOrgs().size === 0) {
<p class="flr-ap__warn flr-ap__warn--indented" role="status">{{ labels().noOrgPicked }}</p>
}
}
</fieldset>

<fieldset class="flr-ap__group">
<legend class="flr-ap__legend">{{ labels().scopesTitle }}</legend>
<div class="flr-ap__presets" role="group" [attr.aria-label]="labels().presetsLabel">
@for (preset of presets(); track preset.id) {
<button
type="button"
class="flr-ap__preset"
[class.flr-ap__preset--on]="activePreset() === preset.id"
[attr.aria-pressed]="activePreset() === preset.id"
(click)="applyPreset(preset)"
>
{{ presetLabel(preset.id) }}
</button>
}
</div>
<div class="flr-ap__products">
@for (product of catalogue(); track product.product) {
<div>
<p class="flr-ap__product">{{ product.label }}</p>
<div class="flr-ap__scopes">
@for (entry of product.scopes; track entry.scope) {
<label class="flr-ap__row flr-ap__row--center">
<input
type="checkbox"
[checked]="pickedScopes().has(entry.scope)"
(change)="toggleScope(entry.scope)"
/>
<span class="flr-ap__scope" [class.flr-ap__scope--admin]="entry.access === 'admin'">{{
entry.scope
}}</span>
</label>
}
</div>
</div>
}
</div>
@if (pickedScopes().size === 0) {
<p class="flr-ap__warn" role="status">{{ labels().noScopePicked }}</p>
}
</fieldset>
Loading
Loading