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

View File

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

View File

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

View File

@ -2,11 +2,146 @@ import filterTests from '../support/filterTests'
filterTests(['smoke', 'all'], () => {
context("Create an Application", () => {
it("should create a new application", () => {
beforeEach(() => {
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()
})
after(() => {
cy.deleteAllApps()
})
it("should create a new Table", () => {
cy.createTable("dog")
cy.wait(1000)

View File

@ -4,9 +4,14 @@ filterTests(["smoke", "all"], () => {
context("Create a User and Assign Roles", () => {
before(() => {
cy.login()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.createAppFromScratch("Initial App")
})
it("should create a user", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.createUser("bbuser@test.com")
cy.get(".spectrum-Table").should("contain", "bbuser")
})
@ -30,7 +35,14 @@ filterTests(["smoke", "all"], () => {
for (let i = 1; i < 3; i++) {
const uuid = () => Cypress._.random(0, 1e6)
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", () => {
before(() => {
cy.login()
cy.deleteAllApps()
cy.createTestApp()
cy.createTable("data")
cy.addColumn("data", "group", "Text")

View File

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

View File

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

View File

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

View File

@ -35,7 +35,9 @@ Cypress.Commands.add("login", () => {
Cypress.Commands.add("createApp", name => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
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("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
@ -51,10 +53,23 @@ Cypress.Commands.add("deleteApp", name => {
.its("body")
.then(val => {
if (val.length > 0) {
cy.searchForApplication(name)
cy.get(".appTable").within(() => {
cy.get(".spectrum-Icon").eq(1).click()
const appId = val.reduce((acc, app) => {
if (name === app.name) {
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 => {
if ($menu.text().includes("Unpublish")) {
cy.get(".spectrum-Menu").contains("Unpublish").click()
@ -80,22 +95,18 @@ Cypress.Commands.add("deleteAllApps", () => {
.its("body")
.then(val => {
for (let i = 0; i < val.length; i++) {
cy.get(".spectrum-Heading")
.eq(1)
.then(app => {
const name = app.text()
cy.get(".title")
.children()
.within(() => {
cy.get(".spectrum-Icon").eq(0).click()
})
cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name)
cy.get(".spectrum-Button--warning").click()
})
cy.reload()
})
const appIdParsed = val[i].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").contains("Delete").click()
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(val[i].name)
cy.get(".spectrum-Button--warning").click()
})
cy.reload()
}
})
})
@ -190,9 +201,11 @@ Cypress.Commands.add("addRowMultiValue", values => {
Cypress.Commands.add("createUser", email => {
// quick hacky recorded way to create a user
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-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click()
//Onboarding type selector
cy.get(
":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 => {
cy.wait(1000)
// Searches for the app
cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear()
cy.get("input").eq(0).type(appName)
})
})
// 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 => {

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}
</StatusLight>
</div>
<div>
<div data-cy={`row_actions_${app.appId}`}>
<Button
size="S"
disabled={app.lockedOther}

View File

@ -9,17 +9,57 @@
import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte"
export let template
let creating = false
const values = writable({ name: "", url: null })
const validation = createValidationStore()
$: validation.check($values)
onMount(async () => {
$values.name = resolveAppName(template, $values.name)
nameToUrl($values.name)
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 applications = svelteGet(apps)
appValidation.name(validation, { apps: applications })
@ -30,6 +70,8 @@
}
async function createNewApp() {
creating = true
try {
// Create form data to create app
let data = new FormData()
@ -64,17 +106,11 @@
await auth.setInitInfo({})
$goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) {
creating = false
console.error(error)
notifications.error("Error creating app")
}
}
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script>
<ModalContent
@ -83,6 +119,15 @@
onConfirm={createNewApp}
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}
<Dropzone
error={$validation.touched.file && $validation.errors.file}
@ -97,20 +142,42 @@
{/if}
<Input
bind:value={$values.name}
disabled={creating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={$auth.user.firstName
placeholder={$auth.user?.firstName
? `${$auth.user.firstName}s app`
: "My app"}
/>
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
label="URL"
placeholder={$values.name
? "/" + encodeURIComponent($values.name).toLowerCase()
: "/"}
/>
<span>
<Input
bind:value={$values.url}
disabled={creating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
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>
<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
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(null, 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(null, appName)
tidyUrl(resolvedUrl)
}
</script>
@ -61,15 +81,17 @@
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
/>
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.name
? "/" + encodeURIComponent($values.name).toLowerCase()
: "/"}
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(null, $values.name)}`}
/>
</ModalContent>

View File

@ -35,13 +35,14 @@ export const url = (validation, { apps, currentApp } = { apps: [] }) => {
validation.addValidator(
"url",
string()
.trim()
.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(
"non-existing-app-url",
"Another app with the same URL already exists",
value => {
// url is nullable
if (!value) {
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,
Body,
Search,
Icon,
Divider,
} from "@budibase/bbui"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
@ -45,6 +46,21 @@
let appName = ""
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)
$: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
@ -79,9 +95,13 @@
}
const initiateAppCreation = () => {
template = null
creationModal.show()
creatingApp = true
if ($apps?.length) {
$goto("/builder/portal/apps/create")
} else {
template = null
creationModal.show()
creatingApp = true
}
}
const initiateAppsExport = () => {
@ -268,148 +288,120 @@
<Page wide>
<Layout noPadding gap="XL">
<div class="title">
<Layout noPadding gap="XS">
<Heading size="M">Welcome to Budibase</Heading>
<Body size="S">
Manage your apps and get a head start with templates
</Body>
</Layout>
{#if loaded}
<div class="title">
<div class="welcome">
<Layout noPadding gap="XS">
<Heading size="M">{welcomeHeader}</Heading>
<Body size="S">
{welcomeBody}
</Body>
</Layout>
<div class="buttons">
{#if cloud}
<Button
size="L"
icon="Export"
quiet
secondary
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
<div class="buttons">
<Button
dataCy="create-app-btn"
size="L"
icon="Add"
cta
on:click={initiateAppCreation}
>
<Icon name="LinkOut" size="S" />
</a>
<div class="card-body">
<div style="color: {item.background}" class="iconAlign">
<svg
width="26px"
height="26px"
class="spectrum-Icon"
style="color:{item.background};"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{item.icon}" />
</svg>
</div>
<div class="iconAlign">
<Body weight="900" size="S">{item.name}</Body>
<div style="font-size: 10px;">
<Body size="S">{item.category.toUpperCase()}</Body>
{createAppButtonText}
</Button>
{#if $apps?.length > 0}
<Button
icon="Experience"
size="L"
quiet
secondary
on:click={$goto("/builder/portal/apps/templates")}
>
Templates
</Button>
{/if}
{#if !$apps?.length}
<Button
dataCy="import-app-btn"
icon="Import"
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>
{/if}
</div>
{/each}
</div>
</Layout>
{#if loaded && enrichedApps.length}
<Layout noPadding gap="S">
<div class="title">
<Detail size="L">My apps</Detail>
<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 class="appTable">
{#each filteredApps as app (app.appId)}
<AppRow
{releaseLock}
{editIcon}
{app}
{unpublishApp}
{viewApp}
{editApp}
{exportApp}
{deleteApp}
{updateApp}
/>
{/each}
</div>
</div>
<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>
</Layout>
{/if}
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div>
@ -459,6 +451,15 @@
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style>
.app-actions {
display: flex;
}
.app-actions :global(> button) {
margin-right: 10px;
}
.title .welcome > .buttons {
padding-top: 30px;
}
.title {
display: flex;
flex-direction: row;
@ -475,13 +476,11 @@
gap: var(--spacing-xl);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.buttons {
flex-direction: row-reverse;
justify-content: flex-end;
@media (max-width: 1000px) {
.img-logo {
display: none;
}
}
.filter {
display: flex;
flex-direction: row;
@ -489,49 +488,6 @@
align-items: center;
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 {
display: grid;
grid-template-rows: auto;
@ -557,7 +513,6 @@
grid-template-columns: 1fr auto;
}
}
.empty-wrapper {
flex: 1 1 auto;
height: 100%;
@ -566,42 +521,8 @@
justify-content: 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 {
width: 160px;
height: 160px;
}
.background-icon {
margin-top: 4px;
margin-right: 4px;
}
</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>
<ButtonGroup>
<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>
</div>
<div class="field">