web: bad default in select (#8258)

* web: fix event propogation in search-select wrappers

Two different patches, an older one that extracted long search
blocks that were cut-and-pasted into a standalone component, and a
newer one that fixed displaying placeholder values properly,
conflicted and broke a relationship that allowed for the values to
be propagated through those standalone components correctly.

This restores the event handling and updates the listener set-ups
with more idiomatic hooks into Lit's event system.

* Updated search-select to properly render with Storybook, and provided a
foundation for testing the Search-Select component with Storybook.

* Accidentally deleted this line while making Sonar accept my test data.

* Fixing a small issue that's bugged me for awhile: there's no reason to manually duplicate what code can duplicate.

* Provided a storybook for testing out the flow search.

Discovered along the way that I'd mis-used a prop-drilling technique which caused the currentFlow
to be "undefined" when pass forward, giving rise to Marc's bug.

I *think* this shakes out the last of the bugs.  Events are passed up correctly and the initial value
is recorded correctly.

* Added comments and prettier had opinions.

* Restoring old variable names; they didn't have to change after all.

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg
2024-01-23 08:54:34 -08:00
committed by GitHub
parent 862aece9fc
commit 830689f1cb
7 changed files with 371 additions and 127 deletions

View File

@ -46,8 +46,8 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
*
* @attr
*/
@property({ attribute: false })
currentFlow: string | undefined;
@property({ type: String })
currentFlow?: string | undefined;
/**
* If true, it is not valid to leave the flow blank.

View File

@ -0,0 +1,136 @@
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { AkFlowSearch } from "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { Meta } from "@storybook/web-components";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { Flow, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
const mockData = {
pagination: {
next: 0,
previous: 0,
count: 2,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 2,
},
results: [
{
pk: "41468774-bef6-4ffb-b675-332d0d8c5d25",
policybindingmodel_ptr_id: "0fb5b872-2734-44bd-ac7e-f23051481a83",
name: "Authorize Application",
slug: "default-provider-authorization-explicit-consent",
title: "Redirecting to %(app)s",
designation: "authorization",
background: "/static/dist/assets/images/flow_background.jpg",
stages: ["8adcdc74-0d3d-48a8-b628-38e3da4081e5"],
policies: [],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url:
"/api/v3/flows/instances/default-provider-authorization-explicit-consent/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "require_authenticated",
},
{
pk: "89f57fd8-fd1e-42be-a5fd-abc13b19529b",
policybindingmodel_ptr_id: "e8526408-c6ee-46e1-bbfe-a1d37c2c02c8",
name: "Authorize Application",
slug: "default-provider-authorization-implicit-consent",
title: "Redirecting to %(app)s",
designation: "authorization",
background: "/static/dist/assets/images/flow_background.jpg",
stages: [],
policies: [],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url:
"/api/v3/flows/instances/default-provider-authorization-implicit-consent/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "require_authenticated",
},
],
};
const metadata: Meta<AkFlowSearch<Flow>> = {
title: "Elements / Select Search / Flow",
component: "ak-flow-search",
parameters: {
docs: {
description: {
component: "A Select Search for Authentication Flows",
},
},
mockData: [
{
url: `${window.location.origin}/api/v3/flows/instances/?designation=authorization&ordering=slug`,
method: "GET",
status: 200,
response: () => mockData,
},
],
},
};
export default metadata;
const container = (testItem: TemplateResult) => {
return html` <div style="background: #fff; padding: 1.0rem;">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="message-pad" style="margin-top: 1em; min-height: 5em;"></ul>
</div>`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value,
null,
2,
)}`;
};
export const Default = () =>
container(
html` <ak-form-element-horizontal
label=${msg("Authorization flow")}
?required=${true}
name="authorizationFlow"
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
@input=${displayChange}
></ak-flow-search
></ak-form-element-horizontal>`,
);
export const WithInitialValue = () =>
container(
html` <ak-form-element-horizontal
label=${msg("Authorization flow")}
?required=${true}
name="authorizationFlow"
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
currentFlow="89f57fd8-fd1e-42be-a5fd-abc13b19529b"
@input=${displayChange}
></ak-flow-search
></ak-form-element-horizontal>`,
);

View File

@ -0,0 +1,145 @@
import { groupBy } from "@goauthentik/app/common/utils";
import { convertToSlug as slugify } from "@goauthentik/common/utils.js";
import "@goauthentik/elements/forms/SearchSelect/ak-search-select";
import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect/ak-search-select";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
type RawSample = [string, string[]];
type Sample = { name: string; pk: string; season: string[] };
// prettier-ignore
const groupedSamples: RawSample[] = [
["Spring", [
"Apples", "Apricots", "Asparagus", "Avocados", "Bananas", "Broccoli",
"Cabbage", "Carrots", "Celery", "Collard Greens", "Garlic", "Herbs", "Kale", "Kiwifruit", "Lemons",
"Lettuce", "Limes", "Mushrooms", "Onions", "Peas", "Pineapples", "Radishes", "Rhubarb", "Spinach",
"Strawberries", "Swiss Chard", "Turnips"]],
["Summer", [
"Apples", "Apricots", "Avocados", "Bananas", "Beets", "Bell Peppers", "Blackberries", "Blueberries",
"Cantaloupe", "Carrots", "Celery", "Cherries", "Corn", "Cucumbers", "Eggplant", "Garlic",
"Green Beans", "Herbs", "Honeydew Melon", "Lemons", "Lima Beans", "Limes", "Mangos", "Okra", "Peaches",
"Plums", "Raspberries", "Strawberries", "Summer Squash", "Tomatillos", "Tomatoes", "Watermelon",
"Zucchini"]],
["Fall", [
"Apples", "Bananas", "Beets", "Bell Peppers", "Broccoli", "Brussels Sprouts", "Cabbage", "Carrots",
"Cauliflower", "Celery", "Collard Greens", "Cranberries", "Garlic", "Ginger", "Grapes", "Green Beans",
"Herbs", "Kale", "Kiwifruit", "Lemons", "Lettuce", "Limes", "Mangos", "Mushrooms", "Onions",
"Parsnips", "Pears", "Peas", "Pineapples", "Potatoes", "Pumpkin", "Radishes", "Raspberries",
"Rutabagas", "Spinach", "Sweet Potatoes", "Swiss Chard", "Turnips", "Winter Squash"]],
["Winter", [
"Apples", "Avocados", "Bananas", "Beets", "Brussels Sprouts", "Cabbage", "Carrots", "Celery",
"Collard Greens", "Grapefruit", "Herbs", "Kale", "Kiwifruit", "Leeks", "Lemons", "Limes", "Onions",
"Oranges", "Parsnips", "Pears", "Pineapples", "Potatoes", "Pumpkin", "Rutabagas",
"Sweet Potatoes", "Swiss Chard", "Turnips", "Winter Squash"]]
];
// WAAAAY too many lines to turn the arrays above into a Sample of
// { name: "Apricots", pk: "apple", season: ["Spring", "Summer"] }
// but it does the job.
const samples = Array.from(
groupedSamples
.reduce((acc, sample) => {
sample[1].forEach((item) => {
const update = (thing: Sample) => ({
...thing,
season: [...thing.season, sample[0]],
});
acc.set(
item,
update(acc.get(item) || { name: item, pk: slugify(item), season: [] }),
);
return acc;
}, acc);
return acc;
}, new Map<string, Sample>())
.values(),
);
samples.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
// All we need is a promise to return our dataset. It doesn't have to be a class-based method a'la
// the authentik API.
const getSamples = (query = "") =>
Promise.resolve(
samples.filter((s) =>
query !== "" ? s.name.toLowerCase().includes(query.toLowerCase()) : true,
),
);
const metadata: Meta<SearchSelect<Sample>> = {
title: "Elements / Search Select ",
component: "ak-search-select",
parameters: {
docs: {
description: {
component: "An implementation of the Patternfly search select pattern",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.detail.value,
null,
2,
)}`;
};
export const Default = () => {
return container(
html`<ak-search-select
.fetchObjects=${getSamples}
.renderElement=${(sample: Sample) => sample.name}
.value=${(sample: Sample) => sample.pk}
@ak-change=${displayChange}
></ak-search-select>`,
);
};
export const Grouped = () => {
return container(
html`<ak-search-select
.fetchObjects=${getSamples}
.renderElement=${(sample: Sample) => sample.name}
.value=${(sample: Sample) => sample.pk}
.groupBy=${(samples: Sample[]) =>
groupBy(samples, (sample: Sample) => sample.season[0] ?? "")}
@ak-change=${displayChange}
></ak-search-select>`,
);
};
export const Selected = () => {
return container(
html`<ak-search-select
.fetchObjects=${getSamples}
.renderElement=${(sample: Sample) => sample.name}
.value=${(sample: Sample) => sample.pk}
.selected=${(sample: Sample) => sample.pk === "herbs"}
@ak-change=${displayChange}
></ak-search-select>`,
);
};

View File

@ -3,6 +3,7 @@ import { PreventFormSubmit } from "@goauthentik/app/elements/forms/helpers";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize";
@ -105,12 +106,17 @@ export class SearchSelect<T> extends CustomEmitterElement(AKElement) {
@state()
error?: APIErrorTypes;
static styles = [PFBase, PFForm, PFFormControl, PFSelect];
static get styles() {
return [PFBase, PFForm, PFFormControl, PFSelect];
}
constructor() {
super();
if (!document.adoptedStyleSheets.includes(PFDropdown)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFDropdown];
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
ensureCSSStyleSheet(PFDropdown),
];
}
this.dropdownContainer = document.createElement("div");
this.observer = new IntersectionObserver(() => {
@ -150,6 +156,7 @@ export class SearchSelect<T> extends CustomEmitterElement(AKElement) {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
}
});
this.objects = objects;