Merge pull request #5002 from Budibase/feature/templates-home-screen

Feature/templates home screen
This commit is contained in:
deanhannigan 2022-03-29 11:10:16 +01:00 committed by GitHub
commit 7a33a77717
21 changed files with 1080 additions and 390 deletions

View File

@ -13,6 +13,7 @@
export let icon = undefined export let icon = undefined
export let active = false export let active = false
export let tooltip = undefined export let tooltip = undefined
export let dataCy
let showTooltip = false let showTooltip = false
</script> </script>
@ -27,6 +28,7 @@
class:active class:active
class="spectrum-Button spectrum-Button--size{size.toUpperCase()}" class="spectrum-Button spectrum-Button--size{size.toUpperCase()}"
{disabled} {disabled}
data-cy={dataCy}
on:click|preventDefault on:click|preventDefault
on:mouseover={() => (showTooltip = true)} on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)} on:focus={() => (showTooltip = true)}

View File

@ -5,6 +5,7 @@
import Divider from "../Divider/Divider.svelte" import Divider from "../Divider/Divider.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Context from "../context" import Context from "../context"
import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte"
export let title = undefined export let title = undefined
export let size = "S" export let size = "S"
@ -102,15 +103,22 @@
<Button group secondary on:click={close}>{cancelText}</Button> <Button group secondary on:click={close}>{cancelText}</Button>
{/if} {/if}
{#if showConfirmButton} {#if showConfirmButton}
<Button <span class="confirm-wrap">
group <Button
cta group
{...$$restProps} cta
disabled={confirmDisabled} {...$$restProps}
on:click={confirm} disabled={confirmDisabled}
> on:click={confirm}
{confirmText} >
</Button> {#if loading}
<ProgressCircle overBackground={true} size="S" />
{/if}
{#if !loading}
{confirmText}
{/if}
</Button>
</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -169,4 +177,8 @@
.spectrum-Dialog-buttonGroup { .spectrum-Dialog-buttonGroup {
padding-left: 0; padding-left: 0;
} }
.confirm-wrap :global(.spectrum-Button-label) {
display: contents;
}
</style> </style>

View File

@ -5,10 +5,14 @@ filterTests(['all'], () => {
before(() => { before(() => {
cy.login() cy.login()
}) })
after(() => {
cy.deleteAllApps()
})
it("should change the icon and colour for an application", () => { it("should change the icon and colour for an application", () => {
// Search for test application // Search for test application
cy.searchForApplication("Cypress Tests") cy.applicationInAppTable("Cypress Tests")
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".spectrum-Icon").eq(1).click()

View File

@ -2,11 +2,146 @@ import filterTests from '../support/filterTests'
filterTests(['smoke', 'all'], () => { filterTests(['smoke', 'all'], () => {
context("Create an Application", () => { context("Create an Application", () => {
it("should create a new application", () => {
beforeEach(() => {
cy.login() cy.login()
cy.createTestApp()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.contains("Cypress Tests").should("exist")
}) })
it("should show the new user UI/UX", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').should("exist")
cy.get(`[data-cy="import-app-btn"]`).should("exist")
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
cy.get(".appTable").should("not.exist")
})
it("should provide filterable templates", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
cy.get(".template-category").its('length').should('be.gt', 1)
cy.get(".template-category-filters .spectrum-ActionButton").its('length').should('be.gt', 2)
cy.get(".template-category-filters .spectrum-ActionButton").eq(1).click()
cy.get(".template-category").should('have.length', 1)
cy.get(".template-category-filters .spectrum-ActionButton").eq(0).click()
cy.get(".template-category").its('length').should('be.gt', 1)
})
it("should enforce a valid url before submission", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
const appName = "A New App"
cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').click({force: true})
cy.get(".spectrum-Modal").within(() => {
//Auto fill
cy.get("input").eq(0).type(appName).should("have.value", appName).blur()
cy.get("input").eq(1).should("have.value", "/a-new-app")
cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled')
//Empty the app url - disabled create
cy.get("input").eq(1).clear().blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").should('be.disabled')
//Invalid url
cy.get("input").eq(1).type("/new app-url").blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").should('be.disabled')
//Specifically alter the url
cy.get("input").eq(1).clear()
cy.get("input").eq(1).type("another-app-name").blur()
cy.get("input").eq(1).should("have.value", "/another-app-name")
cy.get("input").eq(0).should("have.value", appName)
cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled')
})
})
it("should create the first application from scratch", () => {
const appName = "Cypress Tests"
cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.")
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.applicationInAppTable(appName)
cy.deleteApp(appName)
})
it("should generate the first application from a template", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
//### Select nth template and choose to create?
cy.get('.template-category').eq(0).within(() => {
const card = cy.get('.template-card').eq(0).should("exist");
const cardOverlay = card.get('.template-thumbnail-action-overlay').should("exist")
cardOverlay.invoke("show")
cardOverlay.get("button").contains("Use template").should("exist").click({force: true})
})
//### CMD Create app from theme card
cy.get(".spectrum-Modal").should('be.visible')
const templateName = cy.get(".spectrum-Modal .template-thumbnail-text")
templateName.invoke('text')
.then(templateNameText => {
const templateNameParsed = "/"+templateNameText.toLowerCase().replace(/\s+/g, "-")
cy.get(".spectrum-Modal input").eq(0).should("have.value", templateNameText)
cy.get(".spectrum-Modal input").eq(1).should("have.value", templateNameParsed)
cy.get(".spectrum-Modal .spectrum-ButtonGroup").contains("Create app").click()
cy.wait(5000)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.applicationInAppTable(templateNameText)
cy.deleteAllApps()
});
})
it("should display a second application and app filtering", () => {
const appName = "Cypress Tests"
cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.")
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
const secondAppName = "Second App Demo"
cy.deleteApp(secondAppName)
cy.get(`[data-cy="create-app-btn"]`).contains('Create new app').click({force: true})
cy.wait(500)
cy.url().should('include', '/builder/portal/apps/create')
cy.createAppFromScratch(secondAppName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
//Both applications should exist and be searchable
cy.searchForApplication(appName)
cy.searchForApplication(secondAppName)
cy.deleteAllApps()
})
}) })
}) })

View File

@ -7,6 +7,10 @@ filterTests(["smoke", "all"], () => {
cy.createTestApp() cy.createTestApp()
}) })
after(() => {
cy.deleteAllApps()
})
it("should create a new Table", () => { it("should create a new Table", () => {
cy.createTable("dog") cy.createTable("dog")
cy.wait(1000) cy.wait(1000)

View File

@ -4,9 +4,14 @@ filterTests(["smoke", "all"], () => {
context("Create a User and Assign Roles", () => { context("Create a User and Assign Roles", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.createAppFromScratch("Initial App")
}) })
it("should create a user", () => { it("should create a user", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.createUser("bbuser@test.com") cy.createUser("bbuser@test.com")
cy.get(".spectrum-Table").should("contain", "bbuser") cy.get(".spectrum-Table").should("contain", "bbuser")
}) })
@ -30,7 +35,14 @@ filterTests(["smoke", "all"], () => {
for (let i = 1; i < 3; i++) { for (let i = 1; i < 3; i++) {
const uuid = () => Cypress._.random(0, 1e6) const uuid = () => Cypress._.random(0, 1e6)
const name = uuid() const name = uuid()
cy.createApp(name) if(i < 1){
cy.createApp(name)
} else {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
cy.createAppFromScratch(name)
}
} }
} }
}) })

View File

@ -4,6 +4,8 @@ filterTests(['smoke', 'all'], () => {
context("Create a View", () => { context("Create a View", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.deleteAllApps()
cy.createTestApp() cy.createTestApp()
cy.createTable("data") cy.createTable("data")
cy.addColumn("data", "group", "Text") cy.addColumn("data", "group", "Text")

View File

@ -4,6 +4,7 @@ filterTests(["smoke", "all"], () => {
context("REST Datasource Testing", () => { context("REST Datasource Testing", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.deleteAllApps()
cy.createTestApp() cy.createTestApp()
}) })

View File

@ -4,7 +4,7 @@ filterTests(["smoke", "all"], () => {
context("Query Level Transformers", () => { context("Query Level Transformers", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.deleteApp("Cypress Tests") cy.deleteAllApps()
cy.createApp("Cypress Tests") cy.createApp("Cypress Tests")
}) })

View File

@ -1,133 +1,133 @@
import filterTests from "../support/filterTests" import filterTests from "../support/filterTests"
filterTests(['all'], () => { filterTests(['all'], () => {
context("Rename an App", () => { context("Rename an App", () => {
beforeEach(() => { beforeEach(() => {
cy.login() cy.login()
cy.createTestApp() cy.createTestApp()
}) })
it("should rename an unpublished application", () => { it("should rename an unpublished application", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
const appRename = "Cypress Renamed" const appRename = "Cypress Renamed"
// Rename app, Search for app, Confirm name was changed // Rename app, Search for app, Confirm name was changed
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appName, appRename) renameApp(appName, appRename)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(appRename) cy.get(".appTable").find(".title").should("have.length", 1)
cy.get(".appTable").find(".title").should("have.length", 1) cy.applicationInAppTable(appRename)
// Set app name back to Cypress Tests // Set app name back to Cypress Tests
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
renameApp(appRename, appName) renameApp(appRename, appName)
}) })
xit("Should rename a published application", () => { xit("Should rename a published application", () => {
// It is not possible to rename a published application // It is not possible to rename a published application
const appName = "Cypress Tests" const appName = "Cypress Tests"
const appRename = "Cypress Renamed" const appRename = "Cypress Renamed"
// Publish the app // Publish the app
cy.get(".toprightnav") cy.get(".toprightnav")
cy.get(".spectrum-Button").contains("Publish").click({force: true}) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
cy.get(".spectrum-Dialog-grid") cy.get(".spectrum-Dialog-grid")
.within(() => { .within(() => {
// Click publish again within the modal // Click publish again within the modal
cy.get(".spectrum-Button").contains("Publish").click({force: true}) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
}) })
// Rename app, Search for app, Confirm name was changed // Rename app, Search for app, Confirm name was changed
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appName, appRename, true) renameApp(appName, appRename, true)
cy.searchForApplication(appRename) cy.get(".appTable").find(".wrapper").should("have.length", 1)
cy.get(".appTable").find(".wrapper").should("have.length", 1) cy.applicationInAppTable(appRename)
}) })
it("Should try to rename an application to have no name", () => { it("Should try to rename an application to have no name", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appName, " ", false, true) renameApp(appName, " ", false, true)
cy.wait(500) cy.wait(500)
// Close modal and confirm name has not been changed // Close modal and confirm name has not been changed
cy.get(".spectrum-Dialog-grid").contains("Cancel").click() cy.get(".spectrum-Dialog-grid").contains("Cancel").click()
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(appName) cy.applicationInAppTable(appName)
cy.get(".appTable").find(".title").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
}) })
xit("Should create two applications with the same name", () => { xit("Should create two applications with the same name", () => {
// It is not possible to have applications with the same name // It is not possible to have applications with the same name
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
cy.get(".spectrum-Button").contains("Create app").click({force: true}) cy.get(".spectrum-Button").contains("Create app").click({ force: true })
cy.contains(/Start from scratch/).click() cy.contains(/Start from scratch/).click()
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")
.within(() => { .within(() => {
cy.get("input").eq(0).type(appName) cy.get("input").eq(0).type(appName)
cy.get(".spectrum-ButtonGroup").contains("Create app").click({force: true}) 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(".error").should("have.text", "Another app with the same name already exists")
}) })
}) })
it("should validate application names", () => { it("should validate application names", () => {
// App name must be letters, numbers and spaces only // App name must be letters, numbers and spaces only
// This test checks numbers and special characters specifically // This test checks numbers and special characters specifically
const appName = "Cypress Tests" const appName = "Cypress Tests"
const numberName = 12345 const numberName = 12345
const specialCharName = "£$%^" const specialCharName = "£$%^"
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appName, numberName) renameApp(appName, numberName)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(numberName) cy.applicationInAppTable(numberName)
cy.get(".appTable").find(".title").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
renameApp(numberName, specialCharName) renameApp(numberName, specialCharName)
cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only") cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only")
// Set app name back to Cypress Tests // Set app name back to Cypress Tests
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
renameApp(numberName, appName) renameApp(numberName, appName)
}) })
const renameApp = (originalName, changedName, published, noName) => { const renameApp = (originalName, changedName, published, noName) => {
cy.searchForApplication(originalName) cy.applicationInAppTable(originalName)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { if (val.length > 0) {
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".spectrum-Icon").eq(1).click()
}) })
// Check for when an app is published // Check for when an app is published
if (published == true){ if (published == true) {
// Should not have Edit as option, will unpublish app // Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit") cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").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("input").clear()
cy.get(".spectrum-Modal") cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
.within(() => { cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
if (noName == true){ cy.wait(500)
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)
})
}
}) })
} }
}) })
}) })

View File

@ -35,7 +35,9 @@ Cypress.Commands.add("login", () => {
Cypress.Commands.add("createApp", name => { Cypress.Commands.add("createApp", name => {
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
cy.get(".spectrum-Button").contains("Create app").click({ force: true })
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.get(".spectrum-ButtonGroup").contains("Create app").click()
@ -51,10 +53,23 @@ Cypress.Commands.add("deleteApp", name => {
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { if (val.length > 0) {
cy.searchForApplication(name) const appId = val.reduce((acc, app) => {
cy.get(".appTable").within(() => { if (name === app.name) {
cy.get(".spectrum-Icon").eq(1).click() acc = app.appId
}
return acc
}, "")
if (appId == "") {
return
}
const appIdParsed = appId.split("_").pop()
const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
cy.get(actionEleId).within(() => {
cy.get(".spectrum-Icon").eq(0).click()
}) })
cy.get(".spectrum-Menu").then($menu => { cy.get(".spectrum-Menu").then($menu => {
if ($menu.text().includes("Unpublish")) { if ($menu.text().includes("Unpublish")) {
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
@ -80,22 +95,18 @@ Cypress.Commands.add("deleteAllApps", () => {
.its("body") .its("body")
.then(val => { .then(val => {
for (let i = 0; i < val.length; i++) { for (let i = 0; i < val.length; i++) {
cy.get(".spectrum-Heading") const appIdParsed = val[i].appId.split("_").pop()
.eq(1) const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
.then(app => { cy.get(actionEleId).within(() => {
const name = app.text() cy.get(".spectrum-Icon").eq(0).click()
cy.get(".title") })
.children()
.within(() => { cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Icon").eq(0).click() cy.get(".spectrum-Dialog-grid").within(() => {
}) cy.get("input").type(val[i].name)
cy.get(".spectrum-Menu").contains("Delete").click() cy.get(".spectrum-Button--warning").click()
cy.get(".spectrum-Dialog-grid").within(() => { })
cy.get("input").type(name) cy.reload()
cy.get(".spectrum-Button--warning").click()
})
cy.reload()
})
} }
}) })
}) })
@ -190,9 +201,11 @@ Cypress.Commands.add("addRowMultiValue", values => {
Cypress.Commands.add("createUser", email => { Cypress.Commands.add("createUser", email => {
// quick hacky recorded way to create a user // quick hacky recorded way to create a user
cy.contains("Users").click() cy.contains("Users").click()
cy.get(".spectrum-Button--primary").click() cy.get(`[data-cy="add-user"]`).click()
cy.get(".spectrum-Picker-label").click() cy.get(".spectrum-Picker-label").click()
cy.get(".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click() cy.get(".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click()
//Onboarding type selector
cy.get( cy.get(
":nth-child(2) > .spectrum-Form-itemField > .spectrum-Textfield > .spectrum-Textfield-input" ":nth-child(2) > .spectrum-Form-itemField > .spectrum-Textfield > .spectrum-Textfield-input"
) )
@ -312,16 +325,37 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
}) })
}) })
//Filters visible with 1 or more
Cypress.Commands.add("searchForApplication", appName => { Cypress.Commands.add("searchForApplication", appName => {
cy.wait(1000) cy.wait(1000)
// Searches for the app // Searches for the app
cy.get(".filter").then(() => { cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => { cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear()
cy.get("input").eq(0).type(appName) cy.get("input").eq(0).type(appName)
}) })
}) })
// Confirms app exists after search // Confirms app exists after search
cy.get(".appTable").contains(appName) cy.applicationInAppTable(appName)
})
//Assumes there are no others
Cypress.Commands.add("applicationInAppTable", appName => {
cy.get(".appTable").within(() => {
cy.get(".title").contains(appName).should("exist")
})
})
Cypress.Commands.add("createAppFromScratch", appName => {
cy.get(`[data-cy="create-app-btn"]`)
.contains("Start from scratch")
.click({ force: true })
cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(appName).should("have.value", appName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(10000)
})
cy.createTable("Cypress Tests", true)
}) })
Cypress.Commands.add("selectExternalDatasource", datasourceName => { Cypress.Commands.add("selectExternalDatasource", datasourceName => {

View File

@ -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>

View File

@ -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>

View File

@ -61,7 +61,7 @@
{#if app.deployed}Published{:else}Unpublished{/if} {#if app.deployed}Published{:else}Unpublished{/if}
</StatusLight> </StatusLight>
</div> </div>
<div> <div data-cy={`row_actions_${app.appId}`}>
<Button <Button
size="S" size="S"
disabled={app.lockedOther} disabled={app.lockedOther}

View File

@ -9,17 +9,57 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte"
export let template export let template
let creating = false
const values = writable({ name: "", url: null }) const values = writable({ name: "", url: null })
const validation = createValidationStore() const validation = createValidationStore()
$: validation.check($values) $: validation.check($values)
onMount(async () => { onMount(async () => {
$values.name = resolveAppName(template, $values.name)
nameToUrl($values.name)
await setupValidation() await setupValidation()
}) })
const appPrefix = "/app"
$: appUrl = `${window.location.origin}${
$values.url
? `${appPrefix}${$values.url}`
: `${appPrefix}${resolveAppUrl(template, $values.name)}`
}`
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(template, name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(template, appName)
tidyUrl(resolvedUrl)
}
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(apps)
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
@ -30,6 +70,8 @@
} }
async function createNewApp() { async function createNewApp() {
creating = true
try { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
@ -64,17 +106,11 @@
await auth.setInitInfo({}) await auth.setInitInfo({})
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false
console.error(error) console.error(error)
notifications.error("Error creating app") notifications.error("Error creating app")
} }
} }
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script> </script>
<ModalContent <ModalContent
@ -83,6 +119,15 @@
onConfirm={createNewApp} onConfirm={createNewApp}
disabled={!$validation.valid} disabled={!$validation.valid}
> >
{#if template && !template?.fromFile}
<TemplateCard
name={template.name}
imageSrc={template.image}
backgroundColour={template.background}
overlayEnabled={false}
icon={template.icon}
/>
{/if}
{#if template?.fromFile} {#if template?.fromFile}
<Dropzone <Dropzone
error={$validation.touched.file && $validation.errors.file} error={$validation.touched.file && $validation.errors.file}
@ -97,20 +142,42 @@
{/if} {/if}
<Input <Input
bind:value={$values.name} bind:value={$values.name}
disabled={creating}
error={$validation.touched.name && $validation.errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name" label="Name"
placeholder={$auth.user.firstName placeholder={$auth.user?.firstName
? `${$auth.user.firstName}s app` ? `${$auth.user.firstName}s app`
: "My app"} : "My app"}
/> />
<Input <span>
bind:value={$values.url} <Input
error={$validation.touched.url && $validation.errors.url} bind:value={$values.url}
on:blur={() => ($validation.touched.url = true)} disabled={creating}
label="URL" error={$validation.touched.url && $validation.errors.url}
placeholder={$values.name on:blur={() => ($validation.touched.url = true)}
? "/" + encodeURIComponent($values.name).toLowerCase() on:change={tidyUrl($values.url)}
: "/"} label="URL"
/> placeholder={$values.url
? $values.url
: `/${resolveAppUrl(template, $values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
</ModalContent> </ModalContent>
<style>
.app-server {
color: var(--spectrum-global-color-gray-600);
margin-top: 10px;
width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -42,11 +42,31 @@
} }
} }
// auto add slash to url const resolveAppUrl = (template, name) => {
$: { let parsedName
if ($values.url && !$values.url.startsWith("/")) { const resolvedName = resolveAppName(null, name)
$values.url = `/${$values.url}` parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
} }
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(null, appName)
tidyUrl(resolvedUrl)
} }
</script> </script>
@ -61,15 +81,17 @@
bind:value={$values.name} bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name" label="Name"
/> />
<Input <Input
bind:value={$values.url} bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url} error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)} on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL" label="URL"
placeholder={$values.name placeholder={$values.url
? "/" + encodeURIComponent($values.name).toLowerCase() ? $values.url
: "/"} : `/${resolveAppUrl(null, $values.name)}`}
/> />
</ModalContent> </ModalContent>

View File

@ -35,13 +35,14 @@ export const url = (validation, { apps, currentApp } = { apps: [] }) => {
validation.addValidator( validation.addValidator(
"url", "url",
string() string()
.trim()
.nullable() .nullable()
.matches(APP_URL_REGEX, "App URL must not contain spaces") .required("Your application must have a url")
.matches(APP_URL_REGEX, "Please enter a valid url")
.test( .test(
"non-existing-app-url", "non-existing-app-url",
"Another app with the same URL already exists", "Another app with the same URL already exists",
value => { value => {
// url is nullable
if (!value) { if (!value) {
return true return true
} }

View File

@ -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>

View File

@ -11,8 +11,9 @@
notifications, notifications,
Body, Body,
Search, Search,
Icon, Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
@ -45,6 +46,21 @@
let appName = "" let appName = ""
let creatingFromTemplate = false let creatingFromTemplate = false
const resolveWelcomeMessage = (auth, apps) => {
const userWelcome = auth?.user?.firstName
? `Welcome ${auth?.user?.firstName}!`
: "Welcome back!"
return apps?.length ? userWelcome : "Let's create your first app!"
}
$: welcomeHeader = resolveWelcomeMessage($auth, $apps)
$: welcomeBody = $apps?.length
? "Manage your apps and get a head start with templates"
: "Start from scratch or get a head start with one of our templates"
$: createAppButtonText = $apps?.length
? "Create new app"
: "Start from scratch"
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
@ -79,9 +95,13 @@
} }
const initiateAppCreation = () => { const initiateAppCreation = () => {
template = null if ($apps?.length) {
creationModal.show() $goto("/builder/portal/apps/create")
creatingApp = true } else {
template = null
creationModal.show()
creatingApp = true
}
} }
const initiateAppsExport = () => { const initiateAppsExport = () => {
@ -268,148 +288,120 @@
<Page wide> <Page wide>
<Layout noPadding gap="XL"> <Layout noPadding gap="XL">
<div class="title"> {#if loaded}
<Layout noPadding gap="XS"> <div class="title">
<Heading size="M">Welcome to Budibase</Heading> <div class="welcome">
<Body size="S"> <Layout noPadding gap="XS">
Manage your apps and get a head start with templates <Heading size="M">{welcomeHeader}</Heading>
</Body> <Body size="S">
</Layout> {welcomeBody}
</Body>
</Layout>
<div class="buttons"> <div class="buttons">
{#if cloud} <Button
<Button dataCy="create-app-btn"
size="L" size="L"
icon="Export" icon="Add"
quiet cta
secondary on:click={initiateAppCreation}
on:click={initiateAppsExport}
>
Export apps
</Button>
{/if}
<Button
icon="Import"
size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app
</Button>
<Button size="L" icon="Add" cta on:click={initiateAppCreation}>
Create app
</Button>
</div>
</div>
<Layout noPadding gap="S">
<Detail size="L">Quick start templates</Detail>
<div class="grid">
{#each $templates as item}
<div
on:click={() => {
template = item
creationModal.show()
creatingApp = true
}}
class="template-card"
>
<a
href={item.url}
target="_blank"
class="external-link"
on:click|stopPropagation
> >
<Icon name="LinkOut" size="S" /> {createAppButtonText}
</a> </Button>
<div class="card-body"> {#if $apps?.length > 0}
<div style="color: {item.background}" class="iconAlign"> <Button
<svg icon="Experience"
width="26px" size="L"
height="26px" quiet
class="spectrum-Icon" secondary
style="color:{item.background};" on:click={$goto("/builder/portal/apps/templates")}
focusable="false" >
> Templates
<use xlink:href="#spectrum-icon-18-{item.icon}" /> </Button>
</svg> {/if}
</div> {#if !$apps?.length}
<div class="iconAlign"> <Button
<Body weight="900" size="S">{item.name}</Body> dataCy="import-app-btn"
<div style="font-size: 10px;"> icon="Import"
<Body size="S">{item.category.toUpperCase()}</Body> size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app
</Button>
{/if}
</div>
</div>
<div>
<Layout gap="S" justifyItems="center">
<img class="img-logo img-size" alt="logo" src={Logo} />
</Layout>
</div>
<Divider size="S" />
</div>
{#if !$apps?.length && $templates?.length}
<TemplateDisplay templates={$templates} />
{/if}
{#if enrichedApps.length}
<Layout noPadding gap="S">
<div class="title">
<Detail size="L">My apps</Detail>
{#if enrichedApps.length > 1}
<div class="app-actions">
{#if cloud}
<Button
size="M"
icon="Export"
quiet
secondary
on:click={initiateAppsExport}
>
Export apps
</Button>
{/if}
<div class="filter">
<Select
quiet
autoWidth
bind:value={sortBy}
placeholder={null}
options={[
{ label: "Sort by name", value: "name" },
{ label: "Sort by recently updated", value: "updated" },
{ label: "Sort by status", value: "status" },
]}
/>
<Search placeholder="Search" bind:value={searchTerm} />
</div> </div>
</div> </div>
</div> {/if}
</div> </div>
{/each}
</div>
</Layout>
{#if loaded && enrichedApps.length} <div class="appTable">
<Layout noPadding gap="S"> {#each filteredApps as app (app.appId)}
<div class="title"> <AppRow
<Detail size="L">My apps</Detail> {releaseLock}
<div class="filter"> {editIcon}
<Select {app}
quiet {unpublishApp}
autoWidth {viewApp}
bind:value={sortBy} {editApp}
placeholder={null} {exportApp}
options={[ {deleteApp}
{ label: "Sort by name", value: "name" }, {updateApp}
{ label: "Sort by recently updated", value: "updated" }, />
{ label: "Sort by status", value: "status" }, {/each}
]}
/>
<Search placeholder="Search" bind:value={searchTerm} />
</div> </div>
</div> </Layout>
{/if}
<div class="appTable">
{#each filteredApps as app (app.appId)}
<AppRow
{releaseLock}
{editIcon}
{app}
{unpublishApp}
{viewApp}
{editApp}
{exportApp}
{deleteApp}
{updateApp}
/>
{/each}
</div>
</Layout>
{/if}
{#if !enrichedApps.length && !creatingApp && loaded}
<div class="empty-wrapper">
<div class="centered">
<div class="main">
<Layout gap="S" justifyItems="center">
<img class="img-size" alt="logo" src={Logo} />
<div class="new-screen-text">
<Detail size="M">Create a business app in minutes!</Detail>
</div>
<Button on:click={() => initiateAppCreation()} size="M" cta>
<div class="new-screen-button">
<div class="background-icon" style="color: white;">
<Icon name="Add" />
</div>
Create App
</div></Button
>
</Layout>
</div>
</div>
</div>
{/if} {/if}
{#if creatingFromTemplate} {#if creatingFromTemplate}
<div class="empty-wrapper"> <div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p> <p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" /> <Spinner size="10" />
</div> </div>
@ -459,6 +451,15 @@
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>
.app-actions {
display: flex;
}
.app-actions :global(> button) {
margin-right: 10px;
}
.title .welcome > .buttons {
padding-top: 30px;
}
.title { .title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -475,13 +476,11 @@
gap: var(--spacing-xl); gap: var(--spacing-xl);
flex-wrap: wrap; flex-wrap: wrap;
} }
@media (max-width: 640px) { @media (max-width: 1000px) {
.buttons { .img-logo {
flex-direction: row-reverse; display: none;
justify-content: flex-end;
} }
} }
.filter { .filter {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -489,49 +488,6 @@
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.grid {
height: 200px;
display: grid;
overflow: hidden;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-template-rows: minmax(70px, 1fr) minmax(100px, 1fr) minmax(0px, 0);
}
.template-card {
height: 70px;
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer;
display: flex;
position: relative;
}
.template-card:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
.card-body {
display: flex;
align-items: center;
padding: 12px;
}
.external-link {
position: absolute;
top: 5px;
right: 5px;
color: var(--spectrum-global-color-gray-300);
z-index: 99;
}
.external-link:hover {
color: var(--spectrum-global-color-gray-500);
}
.iconAlign {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.appTable { .appTable {
display: grid; display: grid;
grid-template-rows: auto; grid-template-rows: auto;
@ -557,7 +513,6 @@
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
} }
.empty-wrapper { .empty-wrapper {
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;
@ -566,42 +521,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.centered {
width: calc(100% - 350px);
height: calc(100% - 100px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
.new-screen-text {
width: 160px;
text-align: center;
color: #2c2c2c;
font-weight: 600;
}
.new-screen-button {
margin-left: 5px;
height: 20px;
width: 100px;
display: flex;
align-items: center;
}
.img-size { .img-size {
width: 160px; width: 160px;
height: 160px; height: 160px;
} }
.background-icon {
margin-top: 4px;
margin-right: 4px;
}
</style> </style>

View File

@ -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>

View File

@ -71,7 +71,9 @@
<Heading size="S">Users</Heading> <Heading size="S">Users</Heading>
<ButtonGroup> <ButtonGroup>
<Button disabled secondary>Import users</Button> <Button disabled secondary>Import users</Button>
<Button primary on:click={createUserModal.show}>Add user</Button> <Button primary dataCy="add-user" on:click={createUserModal.show}
>Add user</Button
>
</ButtonGroup> </ButtonGroup>
</div> </div>
<div class="field"> <div class="field">