Share validation between app modals, add yup based validation framework, add url to app modals

This commit is contained in:
Rory Powell 2022-01-20 16:01:09 +00:00
parent afa50b7e7a
commit 70788d42b7
10 changed files with 282 additions and 243 deletions

View File

@ -430,13 +430,14 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
// Click to fetch tables
if (skipFetch) {
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Button").contains("Skip table fetch")
cy.get(".spectrum-Button")
.contains("Skip table fetch")
.click({ force: true })
})
}
else {
} else {
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Button").contains("Save and fetch tables")
cy.get(".spectrum-Button")
.contains("Save and fetch tables")
.click({ force: true })
cy.wait(1000)
})

View File

@ -1,12 +1,13 @@
// eslint-disable-next-line no-undef
const breweries = data
const totals = {}
for (let brewery of breweries)
{const state = brewery.state
if (totals[state] == null)
{totals[state] = 1
} else
{totals[state]++
for (let brewery of breweries) {
const state = brewery.state
if (totals[state] == null) {
totals[state] = 1
} else {
totals[state]++
}
}
const entries = Object.entries(totals)

View File

@ -1,15 +1,16 @@
// eslint-disable-next-line no-undef
const breweries = data
const totals = {}
for (let brewery of breweries)
{const state = brewery.state
if (totals[state] == null)
{totals[state] = 1
} else
{totals[state]++
for (let brewery of breweries) {
const state = brewery.state
if (totals[state] == null) {
totals[state] = 1
} else {
totals[state]++
}
}
const stateCodes =
{texas: "tx",
const stateCodes = {
texas: "tx",
colorado: "co",
florida: "fl",
iwoa: "ia",
@ -24,7 +25,7 @@ const stateCodes =
ohio: "oh",
}
const entries = Object.entries(totals)
return entries.map(([state, count]) =>
{stateCodes[state.toLowerCase()]
return entries.map(([state, count]) => {
stateCodes[state.toLowerCase()]
return { state, count, flag: "http://flags.ox3.in/svg/us/${stateCode}.svg" }
})

View File

@ -4,98 +4,45 @@
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore"
import { admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics"
import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants"
import TemplateList from "./TemplateList.svelte"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
export let template
export let inline
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const validator = {
name: string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
),
file: template?.fromFile
? mixed().required("Please choose a file to import")
: null,
}
let submitting = false
let valid = false
let initialTemplateInfo = template?.fromFile || template?.key
$: checkValidity($values, validator)
$: showTemplateSelection = !template && !initialTemplateInfo
const values = writable({ name: "", url: null })
const validation = createValidationStore()
$: validation.check($values)
onMount(async () => {
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.trim()
.required("Your application must have a name")
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => appName.toLowerCase() === value.toLowerCase()
)
}
)
await setupValidation()
})
const checkValidity = async (values, validator) => {
const obj = object().shape(validator)
Object.keys(validator).forEach(key => ($errors[key] = null))
if (template?.fromFile && values.file == null) {
valid = false
return
}
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
const setupValidation = async () => {
await hostingStore.actions.fetchDeployedApps()
const apps = svelteGet(hostingStore).deployedApps
appValidation.name(validation, { apps })
appValidation.url(validation, { apps })
appValidation.file(validation, { template })
// init validation
validation.check($values)
}
async function createNewApp() {
const templateToUse = Object.keys(template).length === 0 ? null : template
submitting = true
// Check a template exists if we are important
if (templateToUse?.fromFile && !$values.file) {
$errors.file = "Please choose a file to import"
valid = false
submitting = false
return false
}
try {
// Create form data to create app
let data = new FormData()
data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null)
if (templateToUse) {
data.append("templateName", templateToUse.name)
data.append("templateKey", templateToUse.key)
if ($values.url) {
data.append("url", $values.url.trim())
}
data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file)
}
@ -109,7 +56,7 @@
analytics.captureEvent(Events.APP.CREATED, {
name: $values.name,
appId: appJson.instance._id,
templateToUse,
templateToUse: template,
})
// Select Correct Application/DB in prep for creating user
@ -137,7 +84,6 @@
} catch (error) {
console.error(error)
notifications.error(error)
submitting = false
}
}
@ -145,60 +91,50 @@
template = null
await auth.setInitInfo({})
}
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script>
{#if showTemplateSelection}
<ModalContent
title={"Get started quickly"}
showConfirmButton={false}
size="L"
onConfirm={() => {
template = {}
return false
}}
showCancelButton={!inline}
showCloseIcon={!inline}
>
<TemplateList
onSelect={(selected, { useImport } = {}) => {
if (!selected) {
template = useImport ? { fromFile: true } : {}
return
}
template = selected
<ModalContent
title={"Create your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp}
{onCancel}
disabled={!$validation.valid}
>
{#if template?.fromFile}
<Dropzone
error={$validation.touched.file && $validation.errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$validation.touched.file = true
}}
/>
</ModalContent>
{:else}
<ModalContent
title={"Name your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp}
onCancel={inline ? onCancel : null}
cancelText={inline ? "Back" : undefined}
showCloseIcon={!inline}
disabled={!valid}
>
{#if template?.fromFile}
<Dropzone
error={$touched.file && $errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$touched.file = true
}}
/>
{/if}
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
label="Name"
placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app`
: "My app"}
/>
</ModalContent>
{/if}
{/if}
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
label="Name"
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()
: "/"}
/>
</ModalContent>

View File

@ -1,120 +1,71 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import {
notifications,
Input,
Modal,
ModalContent,
Body,
} from "@budibase/bbui"
import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
import { hostingStore } from "builderStore"
import { apps } from "stores/portal"
import { string, object } from "yup"
import { onMount } from "svelte"
import { capitalise } from "helpers"
import { APP_NAME_REGEX } from "constants"
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const validator = {
name: string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
),
}
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
export let app
let modal
let valid = false
let dirty = false
$: checkValidity($values, validator)
$: {
// prevent validation by setting name to undefined without an app
if (app) {
$values.name = app?.name
}
}
const values = writable({ name: "", url: null })
const validation = createValidationStore()
$: validation.check($values)
onMount(async () => {
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
)
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => dirty && appName.toLowerCase() === value.toLowerCase()
)
}
)
$values.name = app.name
$values.url = app.url
setupValidation()
})
const checkValidity = async (values, validator) => {
const obj = object().shape(validator)
Object.keys(validator).forEach(key => ($errors[key] = null))
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
const setupValidation = async () => {
await hostingStore.actions.fetchDeployedApps()
const apps = svelteGet(hostingStore).deployedApps
appValidation.name(validation, { apps, currentApp: app })
appValidation.url(validation, { apps, currentApp: app })
// init validation
validation.check($values)
}
async function updateApp() {
try {
// Update App
await apps.update(app.instance._id, { name: $values.name.trim() })
hide()
} catch (error) {
console.error(error)
notifications.error(error)
}
}
export const show = () => {
modal.show()
}
export const hide = () => {
modal.hide()
}
const onCancel = () => {
hide()
}
const onShow = () => {
dirty = false
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script>
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
<ModalContent
title={"Edit app"}
confirmText={"Save"}
onConfirm={updateApp}
disabled={!(valid && dirty)}
>
<Body size="S">Update the name of your app.</Body>
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
on:change={() => (dirty = true)}
label="Name"
/>
</ModalContent>
</Modal>
<ModalContent
title={"Edit app"}
confirmText={"Save"}
onConfirm={updateApp}
disabled={!$validation.valid}
>
<Body size="S">Update the name of your app.</Body>
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
label="Name"
/>
<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()
: "/"}
/>
</ModalContent>

View File

@ -36,4 +36,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View File

@ -0,0 +1,79 @@
import { string, mixed } from "yup"
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants"
export const name = (validation, { apps, currentApp } = { apps: [] }) => {
let existingApps = Object.values(apps)
validation.addValidator(
"name",
string()
.required("Your application must have a name")
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
if (!value) {
return true
}
if (currentApp) {
// filter out the current app if present
existingApps = existingApps
// match the id format of the current app (remove 'app_')
.map(app => ({ ...app, appId: app.appId.substring(4) }))
.filter(app => app.appId !== currentApp.appId)
}
return !existingApps
.map(app => app.name)
.some(appName => appName.toLowerCase() === value.toLowerCase())
}
)
)
}
export const url = (validation, { apps, currentApp } = { apps: {} }) => {
let existingApps = Object.values(apps)
validation.addValidator(
"url",
string()
.nullable()
.matches(APP_URL_REGEX, "App URL must not contain spaces")
.test(
"non-existing-app-url",
"Another app with the same URL already exists",
value => {
// url is nullable
if (!value) {
return true
}
if (currentApp) {
existingApps = existingApps
// match the id format of the current app (remove 'app_')
.map(app => ({ ...app, appId: app.appId.substring(4) }))
// filter out the current app if present
.filter(app => app.appId !== currentApp.appId)
}
return !existingApps
.map(app => app.url)
.some(appUrl => appUrl.toLowerCase() === value.toLowerCase())
}
)
.test("start-with-slash", "Not a valid URL", value => {
// url is nullable
if (!value) {
return true
}
return value.length > 1
})
)
}
export const file = (validation, { template } = {}) => {
const templateToUse =
template && Object.keys(template).length === 0 ? null : template
validation.addValidator(
"file",
templateToUse?.fromFile
? mixed().required("Please choose a file to import")
: null
)
}

View File

@ -0,0 +1,58 @@
import { capitalise } from "helpers"
import { object } from "yup"
import { writable, get } from "svelte/store"
export const createValidationStore = () => {
const DEFAULT = {
errors: {},
touched: {},
valid: false,
}
const validator = {}
const validation = writable(DEFAULT)
const addValidator = (propertyName, propertyValidator) => {
if (!propertyValidator || !propertyName) {
return
}
validator[propertyName] = propertyValidator
}
const check = async values => {
const obj = object().shape(validator)
// clear the previous errors
const properties = Object.keys(validator)
properties.forEach(property => (get(validation).errors[property] = null))
try {
await obj.validate(values, { abortEarly: false })
} catch (error) {
error.inner.forEach(err => {
validation.update(store => {
store.errors[err.path] = capitalise(err.message)
return store
})
})
}
let valid
if (properties.length) {
valid = await obj.isValid(values)
} else {
// don't say valid until validators have been loaded
valid = false
}
validation.update(store => {
store.valid = valid
return store
})
}
return {
subscribe: validation.subscribe,
set: validation.set,
check,
addValidator,
}
}

View File

@ -412,6 +412,11 @@
>
<CreateAppModal {template} />
</Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog
bind:this={deletionModal}
title="Confirm deletion"
@ -438,7 +443,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style>

View File

@ -78,6 +78,11 @@ class QueryRunner {
return this.execute()
}
// check for undefined response
if (!rows) {
rows = []
}
// needs to an array for next step
if (!Array.isArray(rows)) {
rows = [rows]