forked from tsai/budibase
committed by
GitHub
21 changed files with 1079 additions and 389 deletions
@ -1,133 +1,133 @@ |
|||
import filterTests from "../support/filterTests" |
|||
|
|||
filterTests(['all'], () => { |
|||
context("Rename an App", () => { |
|||
beforeEach(() => { |
|||
cy.login() |
|||
cy.createTestApp() |
|||
}) |
|||
context("Rename an App", () => { |
|||
beforeEach(() => { |
|||
cy.login() |
|||
cy.createTestApp() |
|||
}) |
|||
|
|||
it("should rename an unpublished application", () => { |
|||
const appName = "Cypress Tests" |
|||
const appRename = "Cypress Renamed" |
|||
// Rename app, Search for app, Confirm name was changed
|
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, appRename) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.searchForApplication(appRename) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
// Set app name back to Cypress Tests
|
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(appRename, appName) |
|||
const appName = "Cypress Tests" |
|||
const appRename = "Cypress Renamed" |
|||
// Rename app, Search for app, Confirm name was changed
|
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, appRename) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
cy.applicationInAppTable(appRename) |
|||
// Set app name back to Cypress Tests
|
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(appRename, appName) |
|||
}) |
|||
|
|||
|
|||
xit("Should rename a published application", () => { |
|||
// It is not possible to rename a published application
|
|||
const appName = "Cypress Tests" |
|||
const appRename = "Cypress Renamed" |
|||
// Publish the app
|
|||
cy.get(".toprightnav") |
|||
cy.get(".spectrum-Button").contains("Publish").click({force: true}) |
|||
cy.get(".spectrum-Dialog-grid") |
|||
.within(() => { |
|||
// Click publish again within the modal
|
|||
cy.get(".spectrum-Button").contains("Publish").click({force: true}) |
|||
}) |
|||
// Rename app, Search for app, Confirm name was changed
|
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, appRename, true) |
|||
cy.searchForApplication(appRename) |
|||
cy.get(".appTable").find(".wrapper").should("have.length", 1) |
|||
// It is not possible to rename a published application
|
|||
const appName = "Cypress Tests" |
|||
const appRename = "Cypress Renamed" |
|||
// Publish the app
|
|||
cy.get(".toprightnav") |
|||
cy.get(".spectrum-Button").contains("Publish").click({ force: true }) |
|||
cy.get(".spectrum-Dialog-grid") |
|||
.within(() => { |
|||
// Click publish again within the modal
|
|||
cy.get(".spectrum-Button").contains("Publish").click({ force: true }) |
|||
}) |
|||
// Rename app, Search for app, Confirm name was changed
|
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, appRename, true) |
|||
cy.get(".appTable").find(".wrapper").should("have.length", 1) |
|||
cy.applicationInAppTable(appRename) |
|||
}) |
|||
|
|||
it("Should try to rename an application to have no name", () => { |
|||
const appName = "Cypress Tests" |
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, " ", false, true) |
|||
cy.wait(500) |
|||
// Close modal and confirm name has not been changed
|
|||
cy.get(".spectrum-Dialog-grid").contains("Cancel").click() |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.searchForApplication(appName) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
const appName = "Cypress Tests" |
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, " ", false, true) |
|||
cy.wait(500) |
|||
// Close modal and confirm name has not been changed
|
|||
cy.get(".spectrum-Dialog-grid").contains("Cancel").click() |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.applicationInAppTable(appName) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
|
|||
}) |
|||
|
|||
xit("Should create two applications with the same name", () => { |
|||
// It is not possible to have applications with the same name
|
|||
const appName = "Cypress Tests" |
|||
cy.visit(`${Cypress.config().baseUrl}/builder`) |
|||
cy.wait(500) |
|||
cy.get(".spectrum-Button").contains("Create app").click({force: true}) |
|||
cy.contains(/Start from scratch/).click() |
|||
cy.get(".spectrum-Modal") |
|||
// It is not possible to have applications with the same name
|
|||
const appName = "Cypress Tests" |
|||
cy.visit(`${Cypress.config().baseUrl}/builder`) |
|||
cy.wait(500) |
|||
cy.get(".spectrum-Button").contains("Create app").click({ force: true }) |
|||
cy.contains(/Start from scratch/).click() |
|||
cy.get(".spectrum-Modal") |
|||
.within(() => { |
|||
cy.get("input").eq(0).type(appName) |
|||
cy.get(".spectrum-ButtonGroup").contains("Create app").click({force: true}) |
|||
cy.get(".error").should("have.text", "Another app with the same name already exists") |
|||
cy.get("input").eq(0).type(appName) |
|||
cy.get(".spectrum-ButtonGroup").contains("Create app").click({ force: true }) |
|||
cy.get(".error").should("have.text", "Another app with the same name already exists") |
|||
}) |
|||
}) |
|||
|
|||
it("should validate application names", () => { |
|||
// App name must be letters, numbers and spaces only
|
|||
// This test checks numbers and special characters specifically
|
|||
const appName = "Cypress Tests" |
|||
const numberName = 12345 |
|||
const specialCharName = "£$%^" |
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, numberName) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.searchForApplication(numberName) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(numberName, specialCharName) |
|||
cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only") |
|||
// Set app name back to Cypress Tests
|
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(numberName, appName) |
|||
// App name must be letters, numbers and spaces only
|
|||
// This test checks numbers and special characters specifically
|
|||
const appName = "Cypress Tests" |
|||
const numberName = 12345 |
|||
const specialCharName = "£$%^" |
|||
cy.get(".home-logo").click() |
|||
renameApp(appName, numberName) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
cy.applicationInAppTable(numberName) |
|||
cy.get(".appTable").find(".title").should("have.length", 1) |
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(numberName, specialCharName) |
|||
cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only") |
|||
// Set app name back to Cypress Tests
|
|||
cy.reload() |
|||
cy.wait(1000) |
|||
renameApp(numberName, appName) |
|||
}) |
|||
|
|||
const renameApp = (originalName, changedName, published, noName) => { |
|||
cy.searchForApplication(originalName) |
|||
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) |
|||
|
|||
const renameApp = (originalName, changedName, published, noName) => { |
|||
cy.applicationInAppTable(originalName) |
|||
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) |
|||
.its("body") |
|||
.then(val => { |
|||
if (val.length > 0) { |
|||
cy.get(".appTable") |
|||
.within(() => { |
|||
cy.get(".spectrum-Icon").eq(1).click() |
|||
}) |
|||
// Check for when an app is published
|
|||
if (published == true){ |
|||
// Should not have Edit as option, will unpublish app
|
|||
cy.should("not.have.value", "Edit") |
|||
cy.get(".spectrum-Menu").contains("Unpublish").click() |
|||
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() |
|||
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() |
|||
if (val.length > 0) { |
|||
cy.get(".appTable") |
|||
.within(() => { |
|||
cy.get(".spectrum-Icon").eq(1).click() |
|||
}) |
|||
// Check for when an app is published
|
|||
if (published == true) { |
|||
// Should not have Edit as option, will unpublish app
|
|||
cy.should("not.have.value", "Edit") |
|||
cy.get(".spectrum-Menu").contains("Unpublish").click() |
|||
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() |
|||
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() |
|||
} |
|||
cy.contains("Edit").click() |
|||
cy.get(".spectrum-Modal") |
|||
.within(() => { |
|||
if (noName == true) { |
|||
cy.get("input").clear() |
|||
cy.get(".spectrum-Dialog-grid").click() |
|||
.contains("App name must be letters, numbers and spaces only") |
|||
return cy |
|||
} |
|||
cy.contains("Edit").click() |
|||
cy.get(".spectrum-Modal") |
|||
.within(() => { |
|||
if (noName == true){ |
|||
cy.get("input").clear() |
|||
cy.get(".spectrum-Dialog-grid").click() |
|||
.contains("App name must be letters, numbers and spaces only") |
|||
return cy |
|||
} |
|||
cy.get("input").clear() |
|||
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() |
|||
cy.get(".spectrum-ButtonGroup").contains("Save").click({force: true}) |
|||
cy.wait(500) |
|||
}) |
|||
} |
|||
cy.get("input").clear() |
|||
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() |
|||
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) |
|||
cy.wait(500) |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
} |
|||
}) |
|||
}) |
|||
}) |
|||
|
|||
@ -0,0 +1,125 @@ |
|||
<script> |
|||
export let backgroundColour |
|||
export let imageSrc |
|||
export let name |
|||
export let icon |
|||
export let overlayEnabled = true |
|||
|
|||
let imageError = false |
|||
let imageLoaded = false |
|||
|
|||
const imageRenderError = () => { |
|||
imageError = true |
|||
} |
|||
|
|||
const imageLoadSuccess = () => { |
|||
imageLoaded = true |
|||
} |
|||
</script> |
|||
|
|||
<div class="template-card" style="background-color:{backgroundColour};"> |
|||
<div class="template-thumbnail card-body"> |
|||
<img |
|||
alt={name} |
|||
src={imageSrc} |
|||
on:error={imageRenderError} |
|||
on:load={imageLoadSuccess} |
|||
class={`${imageLoaded ? "loaded" : ""}`} |
|||
/> |
|||
<div style={`display:${imageError ? "block" : "none"}`}> |
|||
<svg |
|||
width="26px" |
|||
height="26px" |
|||
class="spectrum-Icon" |
|||
style="color: white" |
|||
focusable="false" |
|||
> |
|||
<use xlink:href="#spectrum-icon-18-{icon}" /> |
|||
</svg> |
|||
</div> |
|||
<div class={overlayEnabled ? "template-thumbnail-action-overlay" : ""}> |
|||
<slot /> |
|||
</div> |
|||
</div> |
|||
<div class="template-thumbnail-text"> |
|||
<div>{name}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.template-thumbnail { |
|||
position: relative; |
|||
} |
|||
|
|||
.template-card:hover .template-thumbnail-action-overlay { |
|||
opacity: 1; |
|||
} |
|||
|
|||
.template-thumbnail-action-overlay { |
|||
position: absolute; |
|||
top: 0px; |
|||
left: 0px; |
|||
width: 100%; |
|||
height: 70%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background-color: rgba(0, 0, 0, 0.7); |
|||
opacity: 0; |
|||
transition: opacity var(--spectrum-global-animation-duration-100) ease; |
|||
border-top-right-radius: inherit; |
|||
border-top-left-radius: inherit; |
|||
} |
|||
|
|||
.template-thumbnail-text { |
|||
position: absolute; |
|||
bottom: 0px; |
|||
display: flex; |
|||
align-items: center; |
|||
height: 30%; |
|||
width: 100%; |
|||
color: var( |
|||
--spectrum-heading-xs-text-color, |
|||
var(--spectrum-alias-heading-text-color) |
|||
); |
|||
background-color: var(--spectrum-global-color-gray-50); |
|||
} |
|||
|
|||
.template-thumbnail-text > div { |
|||
padding-left: 1rem; |
|||
padding-right: 1rem; |
|||
} |
|||
|
|||
.template-card { |
|||
position: relative; |
|||
display: flex; |
|||
border-radius: var(--border-radius-s); |
|||
border: 1px solid var(--spectrum-global-color-gray-300); |
|||
overflow: hidden; |
|||
min-height: 200px; |
|||
} |
|||
|
|||
.template-card > * { |
|||
width: 100%; |
|||
} |
|||
|
|||
.template-card img.loaded { |
|||
display: block; |
|||
} |
|||
|
|||
.template-card img { |
|||
display: none; |
|||
max-width: 100%; |
|||
border-radius: var(--border-radius-s) 0px var(--border-radius-s) 0px; |
|||
} |
|||
|
|||
.template-card:hover { |
|||
background: var(--spectrum-alias-background-color-tertiary); |
|||
} |
|||
|
|||
.card-body { |
|||
padding-left: 1rem; |
|||
padding-top: 1rem; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,152 @@ |
|||
<script> |
|||
import { |
|||
Layout, |
|||
Detail, |
|||
Heading, |
|||
Button, |
|||
Modal, |
|||
ActionGroup, |
|||
ActionButton, |
|||
} from "@budibase/bbui" |
|||
import TemplateCard from "components/common/TemplateCard.svelte" |
|||
import CreateAppModal from "components/start/CreateAppModal.svelte" |
|||
|
|||
export let templates |
|||
|
|||
let selectedTemplateCategory |
|||
let creationModal |
|||
let template |
|||
|
|||
const groupTemplatesByCategory = (templates, categoryFilter) => { |
|||
let grouped = templates.reduce((acc, template) => { |
|||
if ( |
|||
typeof categoryFilter === "string" && |
|||
[categoryFilter].indexOf(template.category) < 0 |
|||
) { |
|||
return acc |
|||
} |
|||
|
|||
acc[template.category] = !acc[template.category] |
|||
? [] |
|||
: acc[template.category] |
|||
acc[template.category].push(template) |
|||
|
|||
return acc |
|||
}, {}) |
|||
return grouped |
|||
} |
|||
|
|||
$: filteredTemplates = groupTemplatesByCategory( |
|||
templates, |
|||
selectedTemplateCategory |
|||
) |
|||
|
|||
$: filteredTemplateCategories = filteredTemplates |
|||
? Object.keys(filteredTemplates).sort() |
|||
: [] |
|||
|
|||
$: templateCategories = templates |
|||
? Object.keys(groupTemplatesByCategory(templates)).sort() |
|||
: [] |
|||
|
|||
const stopAppCreation = () => { |
|||
template = null |
|||
} |
|||
</script> |
|||
|
|||
<div class="template-header"> |
|||
<Layout noPadding gap="S"> |
|||
<Heading size="S">Templates</Heading> |
|||
<div class="template-category-filters spectrum-ActionGroup"> |
|||
<ActionGroup> |
|||
<ActionButton |
|||
selected={!selectedTemplateCategory} |
|||
on:click={() => { |
|||
selectedTemplateCategory = null |
|||
}} |
|||
> |
|||
All |
|||
</ActionButton> |
|||
{#each templateCategories as templateCategoryKey} |
|||
<ActionButton |
|||
dataCy={templateCategoryKey} |
|||
selected={templateCategoryKey == selectedTemplateCategory} |
|||
on:click={() => { |
|||
selectedTemplateCategory = templateCategoryKey |
|||
}} |
|||
> |
|||
{templateCategoryKey} |
|||
</ActionButton> |
|||
{/each} |
|||
</ActionGroup> |
|||
</div> |
|||
</Layout> |
|||
</div> |
|||
|
|||
<div class="template-categories"> |
|||
<Layout gap="XL" noPadding> |
|||
{#each filteredTemplateCategories as templateCategoryKey} |
|||
<div class="template-category" data-cy={templateCategoryKey}> |
|||
<Detail size="M">{templateCategoryKey}</Detail> |
|||
<div class="template-grid"> |
|||
{#each filteredTemplates[templateCategoryKey] as templateEntry} |
|||
<TemplateCard |
|||
name={templateEntry.name} |
|||
imageSrc={templateEntry.image} |
|||
backgroundColour={templateEntry.background} |
|||
icon={templateEntry.icon} |
|||
> |
|||
<Button |
|||
cta |
|||
on:click={() => { |
|||
template = templateEntry |
|||
creationModal.show() |
|||
}} |
|||
> |
|||
Use template |
|||
</Button> |
|||
<a |
|||
href={templateEntry.url} |
|||
target="_blank" |
|||
class="overlay-preview-link spectrum-Button spectrum-Button--sizeM spectrum-Button--secondary" |
|||
on:click|stopPropagation |
|||
> |
|||
Details |
|||
</a> |
|||
</TemplateCard> |
|||
{/each} |
|||
</div> |
|||
</div> |
|||
{/each} |
|||
</Layout> |
|||
</div> |
|||
|
|||
<Modal |
|||
bind:this={creationModal} |
|||
padding={false} |
|||
width="600px" |
|||
on:hide={stopAppCreation} |
|||
> |
|||
<CreateAppModal {template} /> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.template-grid { |
|||
padding-top: 10px; |
|||
display: grid; |
|||
grid-gap: var(--spacing-xl); |
|||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); |
|||
} |
|||
|
|||
a:hover.spectrum-Button.spectrum-Button--secondary.overlay-preview-link { |
|||
background-color: #c8c8c8; |
|||
border-color: #c8c8c8; |
|||
color: #505050; |
|||
} |
|||
|
|||
a.spectrum-Button--secondary.overlay-preview-link { |
|||
margin-top: 20px; |
|||
border-color: #c8c8c8; |
|||
color: #c8c8c8; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,151 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { |
|||
Layout, |
|||
Page, |
|||
notifications, |
|||
Button, |
|||
Heading, |
|||
Body, |
|||
Modal, |
|||
Divider, |
|||
} from "@budibase/bbui" |
|||
import CreateAppModal from "components/start/CreateAppModal.svelte" |
|||
import TemplateDisplay from "components/common/TemplateDisplay.svelte" |
|||
import { onMount } from "svelte" |
|||
import { templates } from "stores/portal" |
|||
|
|||
let loaded = false |
|||
let template |
|||
let creationModal = false |
|||
let creatingApp = false |
|||
|
|||
const welcomeBody = |
|||
"Start from scratch or get a head start with one of our templates" |
|||
const createAppTitle = "Create new app" |
|||
const createAppButtonText = "Start from scratch" |
|||
|
|||
onMount(async () => { |
|||
try { |
|||
await templates.load() |
|||
if ($templates?.length === 0) { |
|||
notifications.error( |
|||
"There was a problem loading quick start templates." |
|||
) |
|||
} |
|||
} catch (error) { |
|||
notifications.error("Error loading apps and templates") |
|||
} |
|||
loaded = true |
|||
}) |
|||
|
|||
const initiateAppCreation = () => { |
|||
template = null |
|||
creationModal.show() |
|||
creatingApp = true |
|||
} |
|||
|
|||
const stopAppCreation = () => { |
|||
template = null |
|||
creatingApp = false |
|||
} |
|||
|
|||
const initiateAppImport = () => { |
|||
template = { fromFile: true } |
|||
creationModal.show() |
|||
creatingApp = true |
|||
} |
|||
</script> |
|||
|
|||
<Page wide> |
|||
<Layout noPadding gap="XL"> |
|||
<span> |
|||
<Button |
|||
quiet |
|||
secondary |
|||
icon={"ChevronLeft"} |
|||
on:click={() => { |
|||
$goto("../") |
|||
}} |
|||
> |
|||
Back |
|||
</Button> |
|||
</span> |
|||
|
|||
<div class="title"> |
|||
<div class="welcome"> |
|||
<Layout noPadding gap="XS"> |
|||
<Heading size="M">{createAppTitle}</Heading> |
|||
<Body size="S"> |
|||
{welcomeBody} |
|||
</Body> |
|||
</Layout> |
|||
|
|||
<div class="buttons"> |
|||
<Button |
|||
dataCy="create-app-btn" |
|||
size="L" |
|||
icon="Add" |
|||
cta |
|||
on:click={initiateAppCreation} |
|||
> |
|||
{createAppButtonText} |
|||
</Button> |
|||
<Button |
|||
dataCy="import-app-btn" |
|||
icon="Import" |
|||
size="L" |
|||
quiet |
|||
secondary |
|||
on:click={initiateAppImport} |
|||
> |
|||
Import app |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<Divider size="S" /> |
|||
|
|||
{#if loaded && $templates?.length} |
|||
<TemplateDisplay templates={$templates} /> |
|||
{/if} |
|||
</Layout> |
|||
</Page> |
|||
|
|||
<Modal |
|||
bind:this={creationModal} |
|||
padding={false} |
|||
width="600px" |
|||
on:hide={stopAppCreation} |
|||
> |
|||
<CreateAppModal {template} /> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.title .welcome > .buttons { |
|||
padding-top: 30px; |
|||
} |
|||
.title { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
gap: var(--spacing-xl); |
|||
flex-wrap: wrap; |
|||
} |
|||
.buttons { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: flex-start; |
|||
align-items: center; |
|||
gap: var(--spacing-xl); |
|||
flex-wrap: wrap; |
|||
} |
|||
@media (max-width: 640px) { |
|||
.buttons { |
|||
flex-direction: row-reverse; |
|||
justify-content: flex-end; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,43 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { Layout, Page, notifications, Button } from "@budibase/bbui" |
|||
import TemplateDisplay from "components/common/TemplateDisplay.svelte" |
|||
import { onMount } from "svelte" |
|||
import { templates } from "stores/portal" |
|||
|
|||
let loaded = false |
|||
|
|||
onMount(async () => { |
|||
try { |
|||
await templates.load() |
|||
if ($templates?.length === 0) { |
|||
notifications.error( |
|||
"There was a problem loading quick start templates." |
|||
) |
|||
} |
|||
} catch (error) { |
|||
notifications.error("Error loading apps and templates") |
|||
} |
|||
loaded = true |
|||
}) |
|||
</script> |
|||
|
|||
<Page wide> |
|||
<Layout noPadding gap="XL"> |
|||
<span> |
|||
<Button |
|||
quiet |
|||
secondary |
|||
icon={"ChevronLeft"} |
|||
on:click={() => { |
|||
$goto("../") |
|||
}} |
|||
> |
|||
Back |
|||
</Button> |
|||
</span> |
|||
{#if loaded && $templates?.length} |
|||
<TemplateDisplay templates={$templates} /> |
|||
{/if} |
|||
</Layout> |
|||
</Page> |
|||
Loading…
Reference in new issue