web: Baby steps
I become frustrated with my inability to make any progress on this project, so I decided to reach for a tool that I consider highly reliable but also incredibly time-consuming and boring: test driven development. In this case, I wrote a story about how I wanted to see the first page rendered: just put the HTML tag, completely unadorned, that will handle the first page of the wizard. Then, add an event handler that will send the updated content to some parent object, since what we really want is to orchestrate the state of the user's input with a centralized location. Then, rather than fiddling with the attributes and properties of the various pages, I wanted them to be able to "look up" the values they want, much as we'd expect a standalone form to be able to pull its values from the server, so I added a context object that receives the update event and incorporates the new knowledge about the state of the process into itself. The result is surprisingly satisfying: the first page renders cleanly, displays the content that we want, and as we fiddle with, we can *watch in real time* as the results of the context are updated and retransmitted to all receiving objects. And the sending object gets the results so it re-renders, but it ends up looking the same as it was before the render.
This commit is contained in:
parent
bbc47e0fce
commit
bf26e5d11e
|
@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme";
|
|||
|
||||
addons.setConfig({
|
||||
theme: authentikTheme,
|
||||
enableShortcuts: false,
|
||||
});
|
||||
|
|
|
@ -32,7 +32,7 @@ import {
|
|||
import "./components/ak-backchannel-input";
|
||||
import "./components/ak-provider-search-input";
|
||||
|
||||
const policyOptions = [
|
||||
export const policyOptions = [
|
||||
{
|
||||
label: "any",
|
||||
value: PolicyEngineMode.Any,
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import { ApplicationRequest, CoreApi, Provider } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-initial")
|
||||
export class InitialApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Application details");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
const name = data.name as string;
|
||||
let slug = convertToSlug(name || "");
|
||||
// Check if an application with the generated slug already exists
|
||||
const apps = await new CoreApi(DEFAULT_CONFIG).coreApplicationsList({
|
||||
search: slug,
|
||||
});
|
||||
if (apps.results.filter((app) => app.slug == slug)) {
|
||||
slug += "-1";
|
||||
}
|
||||
this.host.state["slug"] = slug;
|
||||
this.host.state["name"] = name;
|
||||
this.host.addActionBefore(msg("Create application"), async (): Promise<boolean> => {
|
||||
const req: ApplicationRequest = {
|
||||
name: name || "",
|
||||
slug: slug,
|
||||
metaPublisher: data.metaPublisher as string,
|
||||
metaDescription: data.metaDescription as string,
|
||||
};
|
||||
if ("provider" in this.host.state) {
|
||||
req.provider = (this.host.state["provider"] as Provider).pk;
|
||||
}
|
||||
if ("link" in this.host.state) {
|
||||
req.metaLaunchUrl = this.host.state["link"] as string;
|
||||
}
|
||||
this.host.state["app"] = await new CoreApi(DEFAULT_CONFIG).coreApplicationsCreate({
|
||||
applicationRequest: req,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
|
||||
<input type="text" value="" class="pf-c-form-control" required />
|
||||
<p class="pf-c-form__helper-text">${msg("Application's display Name.")}</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-group ?expanded=${true}>
|
||||
<span slot="header"> ${msg("Additional UI settings")} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Description")}
|
||||
name="metaDescription"
|
||||
>
|
||||
<textarea class="pf-c-form-control"></textarea>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Publisher")} name="metaPublisher">
|
||||
<input type="text" value="" class="pf-c-form-control" />
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { css } from "lit";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export const styles = [
|
||||
PFBase,
|
||||
PFCard,
|
||||
PFButton,
|
||||
PFForm,
|
||||
PFAlert,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
PFSwitch,
|
||||
css`
|
||||
select[multiple] {
|
||||
height: 15em;
|
||||
}
|
||||
`,
|
||||
];
|
|
@ -0,0 +1,103 @@
|
|||
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { consume } from "@lit-labs/context";
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { state } from "@lit/reactive-element/decorators/state.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import { styles as AwadStyles } from "./ak-application-wizard-application-details.css";
|
||||
|
||||
import type { WizardState } from "./ak-application-wizard-context";
|
||||
import applicationWizardContext from "./ak-application-wizard-context-name";
|
||||
|
||||
@customElement("ak-application-wizard-application-details")
|
||||
export class ApplicationWizardApplicationDetails extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return AwadStyles;
|
||||
}
|
||||
|
||||
@consume({ context: applicationWizardContext, subscribe: true })
|
||||
@state()
|
||||
private wizard!: WizardState;
|
||||
|
||||
handleChange(ev: Event) {
|
||||
const value = ev.target.type === "checkbox" ? ev.target.checked : ev.target.value;
|
||||
|
||||
this.dispatchCustomEvent("ak-wizard-update", {
|
||||
...this.wizard,
|
||||
application: {
|
||||
...this.wizard.application,
|
||||
[ev.target.name]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
|
||||
<ak-text-input
|
||||
name="name"
|
||||
value=${this.wizard.application?.name}
|
||||
label=${msg("Name")}
|
||||
required
|
||||
help=${msg("Application's display Name.")}
|
||||
></ak-text-input>
|
||||
<ak-text-input
|
||||
name="slug"
|
||||
value=${this.wizard.application?.slug}
|
||||
label=${msg("Slug")}
|
||||
required
|
||||
help=${msg("Internal application name used in URLs.")}
|
||||
></ak-text-input>
|
||||
<ak-text-input
|
||||
name="group"
|
||||
value=${this.wizard.application?.group}
|
||||
label=${msg("Group")}
|
||||
help=${msg(
|
||||
"Optionally enter a group name. Applications with identical groups are shown grouped together."
|
||||
)}
|
||||
></ak-text-input>
|
||||
<ak-radio-input
|
||||
label=${msg("Policy engine mode")}
|
||||
required
|
||||
name="policyEngineMode"
|
||||
.options=${policyOptions}
|
||||
.value=${this.wizard.application?.policyEngineMode}
|
||||
></ak-radio-input>
|
||||
<ak-form-group>
|
||||
<span slot="header"> ${msg("UI settings")} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-text-input
|
||||
name="metaLaunchUrl"
|
||||
label=${msg("Launch URL")}
|
||||
value=${ifDefined(this.wizard.application?.metaLaunchUrl)}
|
||||
help=${msg(
|
||||
"If left empty, authentik will try to extract the launch URL based on the selected provider."
|
||||
)}
|
||||
></ak-text-input>
|
||||
<ak-switch-input
|
||||
name="openInNewTab"
|
||||
?checked=${first(this.wizard.application?.openInNewTab, false)}
|
||||
label=${msg("Open in new tab")}
|
||||
help=${msg(
|
||||
"If checked, the launch URL will open in a new browser tab or window from the user's application library."
|
||||
)}
|
||||
>
|
||||
</ak-switch-input>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
</form>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApplicationWizardApplicationDetails;
|
|
@ -0,0 +1,5 @@
|
|||
import {createContext} from '@lit-labs/context';
|
||||
|
||||
export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context'));
|
||||
|
||||
export default ApplicationWizardContext;
|
|
@ -0,0 +1,74 @@
|
|||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { provide } from "@lit-labs/context";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { property } from "@lit/reactive-element/decorators/property.js";
|
||||
import { LitElement, html } from "lit";
|
||||
|
||||
import {
|
||||
Application,
|
||||
LDAPProvider,
|
||||
OAuth2Provider,
|
||||
ProxyProvider,
|
||||
RadiusProvider,
|
||||
SAMLProvider,
|
||||
SCIMProvider,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import applicationWizardContext from "./ak-application-wizard-context-name";
|
||||
|
||||
// my-context.ts
|
||||
|
||||
type OneOfProvider =
|
||||
| Partial<SCIMProvider>
|
||||
| Partial<SAMLProvider>
|
||||
| Partial<RadiusProvider>
|
||||
| Partial<ProxyProvider>
|
||||
| Partial<OAuth2Provider>
|
||||
| Partial<LDAPProvider>;
|
||||
|
||||
export type WizardState = {
|
||||
step: number;
|
||||
application: Partial<Application>;
|
||||
provider: OneOfProvider;
|
||||
};
|
||||
|
||||
@customElement("ak-application-wizard-context")
|
||||
export class AkApplicationWizardContext extends CustomListenerElement(LitElement) {
|
||||
/**
|
||||
* Providing a context at the root element
|
||||
*/
|
||||
@provide({ context: applicationWizardContext })
|
||||
@property({ attribute: false })
|
||||
wizardState: WizardState = {
|
||||
step: 0,
|
||||
application: {},
|
||||
provider: {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleUpdate = this.handleUpdate.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addCustomListener("ak-wizard-update", this.handleUpdate);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeCustomListener("ak-wizard-update", this.handleUpdate);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
handleUpdate(event: CustomEvent<WizardState>) {
|
||||
delete event.detail.target;
|
||||
this.wizardState = event.detail;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default AkApplicationWizardContext;
|
|
@ -22,6 +22,29 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
|||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
name: msg("Application Details"),
|
||||
view: () =>
|
||||
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
|
||||
},
|
||||
{
|
||||
name: msg("Authentication Method"),
|
||||
view: () =>
|
||||
html`<ak-application-wizard-authentication-choice></ak-application-wizard-authentication-choice>`,
|
||||
},
|
||||
{
|
||||
name: msg("Authentication Details"),
|
||||
view: () =>
|
||||
html`<ak-application-wizard-authentication-details></ak-application-wizard-authentication-details>`,
|
||||
},
|
||||
{
|
||||
name: msg("Save Application"),
|
||||
view: () =>
|
||||
html`<ak-application-wizard-application-commit></ak-application-wizard-application-commit>`,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("ak-application-wizard")
|
||||
export class ApplicationWizard extends AKElement {
|
||||
static get styles(): CSSResult[] {
|
|
@ -0,0 +1,18 @@
|
|||
import { consume } from "@lit-labs/context";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { state } from "@lit/reactive-element/decorators/state.js";
|
||||
import { LitElement, html } from "lit";
|
||||
|
||||
import type { WizardState } from "../ak-application-wizard-context";
|
||||
import applicationWizardContext from "../ak-application-wizard-context-name";
|
||||
|
||||
@customElement("ak-application-context-display-for-test")
|
||||
export class ApplicationContextDisplayForTest extends LitElement {
|
||||
@consume({ context: applicationWizardContext, subscribe: true })
|
||||
@state()
|
||||
private wizard!: WizardState;
|
||||
|
||||
render() {
|
||||
return html`<div><pre>${JSON.stringify(this.wizard, null, 2)}</pre></div>`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { Meta } from "@storybook/web-components";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../ak-application-wizard-application-details";
|
||||
import AkApplicationWizardApplicationDetails from "../ak-application-wizard-application-details";
|
||||
import "../ak-application-wizard-context";
|
||||
import "./ak-application-context-display-for-test";
|
||||
|
||||
const metadata: Meta<AkApplicationWizardApplicationDetails> = {
|
||||
title: "Elements / Application Wizard / Page 1",
|
||||
component: "ak-application-wizard-application-details",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "The first page of the application wizard",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
const container = (testItem: TemplateResult) =>
|
||||
html` <div style="background: #fff; padding: 1em">
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
${testItem}
|
||||
</div>`;
|
||||
|
||||
export const PageOne = () => {
|
||||
return container(
|
||||
html`<ak-application-wizard-context>
|
||||
<ak-application-wizard-application-details></ak-application-wizard-application-details>
|
||||
<ak-application-context-display-for-test></ak-application-context-display-for-test>
|
||||
</ak-application-wizard-context>`
|
||||
);
|
||||
};
|
82
web/src/components/ak-wizard/ak-wizard.stories.ts
Normal file
82
web/src/components/ak-wizard/ak-wizard.stories.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { Meta } from "@storybook/web-components";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "./ak-wizard";
|
||||
import AkWizard from "./ak-wizard";
|
||||
|
||||
const metadata: Meta<AkWizard> = {
|
||||
title: "Components / Wizard",
|
||||
component: "ak-wizard",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "A Wizard for wrapping multiple steps",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
const container = (testItem: TemplateResult) =>
|
||||
html` <div style="background: #fff; padding: 2em">
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
<ak-message-container></ak-message-container>
|
||||
${testItem}
|
||||
<p>Messages received from the button:</p>
|
||||
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
|
||||
</div>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const displayMessage = (result: any) => {
|
||||
const doc = new DOMParser().parseFromString(
|
||||
`<li><i>Event</i>: ${
|
||||
"result" in result.detail ? result.detail.result : result.detail.error
|
||||
}</li>`,
|
||||
"text/xml",
|
||||
);
|
||||
const target = document.querySelector("#action-button-message-pad");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
target!.appendChild(doc.firstChild!);
|
||||
};
|
||||
|
||||
window.addEventListener("ak-button-success", displayMessage);
|
||||
window.addEventListener("ak-button-failure", displayMessage);
|
||||
|
||||
export const ButtonWithSuccess = () => {
|
||||
const run = () =>
|
||||
new Promise<string>(function (resolve) {
|
||||
setTimeout(function () {
|
||||
resolve("Success!");
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
return container(
|
||||
html`<ak-action-button class="pf-m-primary" .apiRequest=${run}
|
||||
>3 Seconds</ak-action-button
|
||||
>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const ButtonWithError = () => {
|
||||
const run = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("This is the error message."));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
return container(
|
||||
html` <ak-action-button class="pf-m-secondary" .apiRequest=${run}
|
||||
>3 Seconds</ak-action-button
|
||||
>`,
|
||||
);
|
||||
};
|
99
web/src/components/ak-wizard/ak-wizard.ts
Normal file
99
web/src/components/ak-wizard/ak-wizard.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { property } from "@lit/reactive-element/decorators/property.js";
|
||||
import { state } from "@lit/reactive-element/decorators/state.js";
|
||||
import { html, nothing } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
/**
|
||||
* @class AkWizard
|
||||
*
|
||||
* @element ak-wizard
|
||||
*
|
||||
* The ak-wizard element exists to guide users through a complex task by dividing it into sections
|
||||
* and granting them successive access to future sections. Our wizard has four "zones": The header,
|
||||
* the breadcrumb toolbar, the navigation controls, and the content of the panel.
|
||||
*
|
||||
*/
|
||||
|
||||
type WizardStep = {
|
||||
name: string;
|
||||
constructor: () => TemplateResult;
|
||||
};
|
||||
|
||||
export class AkWizard extends ModalButton {
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property()
|
||||
wizardtitle?: string;
|
||||
|
||||
@property()
|
||||
description?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
renderModalInner() {
|
||||
return html`<div class="pf-c-wizard">
|
||||
${this.renderWizardHeader()}
|
||||
<div class="pf-c-wizard__outer-wrap">
|
||||
<div class="pf-c-wizard__inner-wrap">${this.renderWizardNavigation()}</div>
|
||||
</div>
|
||||
</div> `;
|
||||
}
|
||||
|
||||
renderWizardHeader() {
|
||||
const renderCancelButton = () =>
|
||||
html`<button
|
||||
class="pf-c-button pf-m-plain pf-c-wizard__close"
|
||||
type="button"
|
||||
aria-label="${msg("Close")}"
|
||||
@click=${this.handleClose}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
|
||||
return html`<div class="pf-c-wizard__header">
|
||||
${this.required ? nothing : renderCancelButton()}
|
||||
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.wizardtitle}</h1>
|
||||
<p class="pf-c-wizard__description">${this.description}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderWizardNavigation() {
|
||||
const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0;
|
||||
|
||||
const renderNavStep = (step: string, idx: number) => {
|
||||
return html`
|
||||
<li class="pf-c-wizard__nav-item">
|
||||
<button
|
||||
class="pf-c-wizard__nav-link ${idx === currentIdx ? "pf-m-current" : ""}"
|
||||
?disabled=${currentIdx < idx}
|
||||
@click=${() => {
|
||||
const stepEl = this.querySelector<WizardPage>(`[slot=${step}]`);
|
||||
if (stepEl) {
|
||||
this.currentStep = stepEl;
|
||||
}
|
||||
}}
|
||||
>
|
||||
${this.querySelector<WizardPage>(`[slot=${step}]`)?.sidebarLabel()}
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
};
|
||||
|
||||
return html` <nav class="pf-c-wizard__nav">
|
||||
<ol class="pf-c-wizard__nav-list">
|
||||
${map(this.steps, renderNavStep)}
|
||||
</ol>
|
||||
</nav>`;
|
||||
}
|
||||
}
|
Reference in a new issue