Merge branch 'master' into feature/add-edit-button-custom-renderer-for-grid-component

This commit is contained in:
Kevin Åberg Kultalahti 2020-10-16 10:37:22 +02:00 committed by GitHub
commit 845f0de365
26 changed files with 218 additions and 97 deletions

View File

@ -55,6 +55,11 @@ context("Create a View", () => {
cy.get(".menu-container") cy.get(".menu-container")
.find("select") .find("select")
.eq(0) .eq(0)
.select("Statistics")
cy.wait(50)
cy.get(".menu-container")
.find("select")
.eq(1)
.select("age") .select("age")
cy.contains("Save").click() cy.contains("Save").click()
cy.get("thead th div").should($headers => { cy.get("thead th div").should($headers => {

View File

@ -24,6 +24,7 @@ import {
saveCurrentPreviewItem as _saveCurrentPreviewItem, saveCurrentPreviewItem as _saveCurrentPreviewItem,
saveScreenApi as _saveScreenApi, saveScreenApi as _saveScreenApi,
regenerateCssForCurrentScreen, regenerateCssForCurrentScreen,
regenerateCssForScreen,
generateNewIdsForComponent, generateNewIdsForComponent,
getComponentDefinition, getComponentDefinition,
} from "../storeUtils" } from "../storeUtils"
@ -98,6 +99,29 @@ const setPackage = (store, initial) => async pkg => {
}, },
} }
// if the app has just been created
// we need to build the CSS and save
if (pkg.justCreated) {
const generateInitialPageCss = async name => {
const page = pkg.pages[name]
regenerateCssForScreen(page)
for (let screen of page._screens) {
regenerateCssForScreen(screen)
}
await api.post(`/_builder/api/${pkg.application._id}/pages/${name}`, {
page: {
componentLibraries: pkg.application.componentLibraries,
...page,
},
screens: page._screens,
})
}
generateInitialPageCss("main")
generateInitialPageCss("unauthenticated")
pkg.justCreated = false
}
initial.libraries = pkg.application.componentLibraries initial.libraries = pkg.application.componentLibraries
initial.components = await fetchComponentLibDefinitions(pkg.application._id) initial.components = await fetchComponentLibDefinitions(pkg.application._id)
initial.name = pkg.application.name initial.name = pkg.application.name
@ -156,8 +180,8 @@ const createScreen = store => async screen => {
state.currentPreviewItem = screen state.currentPreviewItem = screen
state.currentComponentInfo = screen.props state.currentComponentInfo = screen.props
state.currentFrontEndType = "screen" state.currentFrontEndType = "screen"
savePromise = _saveScreen(store, state, screen)
regenerateCssForCurrentScreen(state) regenerateCssForCurrentScreen(state)
savePromise = _saveScreen(store, state, screen)
return state return state
}) })
await savePromise await savePromise

View File

@ -79,10 +79,12 @@ export const walkProps = (props, action, cancelToken = null) => {
} }
} }
export const regenerateCssForScreen = screen => {
screen._css = generate_screen_css([screen.props])
}
export const regenerateCssForCurrentScreen = state => { export const regenerateCssForCurrentScreen = state => {
state.currentPreviewItem._css = generate_screen_css([ regenerateCssForScreen(state.currentPreviewItem)
state.currentPreviewItem.props,
])
return state return state
} }

View File

@ -9,21 +9,23 @@
export let view = {} export let view = {}
let data = [] let data = []
let loading = false
$: name = view.name $: name = view.name
// Fetch rows for specified view // Fetch rows for specified view
$: { $: {
if (!name.startsWith("all_")) { if (!name.startsWith("all_")) {
fetchViewData(name, view.field, view.groupBy) loading = true
fetchViewData(name, view.field, view.groupBy, view.calculation)
} }
} }
async function fetchViewData(name, field, groupBy) { async function fetchViewData(name, field, groupBy, calculation) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (field) { if (calculation) {
params.set("field", field) params.set("field", field)
params.set("stats", true) params.set("calculation", calculation)
} }
if (groupBy) { if (groupBy) {
params.set("group", groupBy) params.set("group", groupBy)
@ -31,10 +33,11 @@
const QUERY_VIEW_URL = `/api/views/${name}?${params}` const QUERY_VIEW_URL = `/api/views/${name}?${params}`
const response = await api.get(QUERY_VIEW_URL) const response = await api.get(QUERY_VIEW_URL)
data = await response.json() data = await response.json()
loading = false
} }
</script> </script>
<Table title={decodeURI(name)} schema={view.schema} {data}> <Table title={decodeURI(name)} schema={view.schema} {data} {loading}>
<FilterButton {view} /> <FilterButton {view} />
<CalculateButton {view} /> <CalculateButton {view} />
{#if view.calculation} {#if view.calculation}

View File

@ -9,7 +9,11 @@
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
<TextButton text small on:click={dropdown.show} active={!!view.field}> <TextButton
text
small
on:click={dropdown.show}
active={view.field && view.calculation}>
<Icon name="calculate" /> <Icon name="calculate" />
Calculate Calculate
</TextButton> </TextButton>

View File

@ -9,6 +9,14 @@
name: "Statistics", name: "Statistics",
key: "stats", key: "stats",
}, },
{
name: "Count",
key: "count",
},
{
name: "Sum",
key: "sum",
},
] ]
export let view = {} export let view = {}
@ -20,11 +28,12 @@
$: fields = $: fields =
viewTable && viewTable &&
Object.keys(viewTable.schema).filter( Object.keys(viewTable.schema).filter(
field => viewTable.schema[field].type === "number" field =>
view.calculation === "count" ||
viewTable.schema[field].type === "number"
) )
function saveView() { function saveView() {
if (!view.calculation) view.calculation = "stats"
backendUiStore.actions.views.save(view) backendUiStore.actions.views.save(view)
notifier.success(`View ${view.name} saved.`) notifier.success(`View ${view.name} saved.`)
onClosed() onClosed()
@ -35,25 +44,26 @@
<div class="actions"> <div class="actions">
<h5>Calculate</h5> <h5>Calculate</h5>
<div class="input-group-row"> <div class="input-group-row">
<!-- <p>The</p> <p>The</p>
<Select secondary thin bind:value={view.calculation}> <Select secondary thin bind:value={view.calculation}>
<option value="">Choose an option</option> <option value={''}>Choose an option</option>
{#each CALCULATIONS as calculation} {#each CALCULATIONS as calculation}
<option value={calculation.key}>{calculation.name}</option> <option value={calculation.key}>{calculation.name}</option>
{/each} {/each}
</Select> </Select>
<p>of</p> --> {#if view.calculation}
<p>The statistics of</p> <p>of</p>
<Select secondary thin bind:value={view.field}> <Select secondary thin bind:value={view.field}>
<option value="">Choose an option</option> <option value={''}>You must choose an option</option>
{#each fields as field} {#each fields as field}
<option value={field}>{field}</option> <option value={field}>{field}</option>
{/each} {/each}
</Select> </Select>
{/if}
</div> </div>
<div class="footer"> <div class="footer">
<Button secondary on:click={onClosed}>Cancel</Button> <Button secondary on:click={onClosed}>Cancel</Button>
<Button primary on:click={saveView}>Save</Button> <Button primary on:click={saveView} disabled={!view.field}>Save</Button>
</div> </div>
</div> </div>

View File

@ -72,8 +72,8 @@
<div class="actions"> <div class="actions">
<Input label="Name" thin bind:value={field.name} /> <Input label="Name" thin bind:value={field.name} />
{#if !originalName}
<Select <Select
disabled={originalName}
secondary secondary
thin thin
label="Type" label="Type"
@ -83,7 +83,6 @@
<option value={field.type}>{field.name}</option> <option value={field.type}>{field.name}</option>
{/each} {/each}
</Select> </Select>
{/if}
{#if field.type !== 'link'} {#if field.type !== 'link'}
<Toggle <Toggle

View File

@ -9,6 +9,10 @@
name: "Equals", name: "Equals",
key: "EQUALS", key: "EQUALS",
}, },
{
name: "Not Equals",
key: "NOT_EQUALS",
},
{ {
name: "Less Than", name: "Less Than",
key: "LT", key: "LT",

View File

@ -84,7 +84,7 @@
<li>No Users found</li> <li>No Users found</li>
{/each} {/each}
</ul> </ul>
{:catch error} {:catch err}
Something went wrong when trying to fetch users. Please refresh (CMD + R / Something went wrong when trying to fetch users. Please refresh (CMD + R /
CTRL + R) the page and try again. CTRL + R) the page and try again.
{/await} {/await}

View File

@ -154,6 +154,7 @@
const pkg = await applicationPkg.json() const pkg = await applicationPkg.json()
if (applicationPkg.ok) { if (applicationPkg.ok) {
backendUiStore.actions.reset() backendUiStore.actions.reset()
pkg.justCreated = true
await store.setPackage(pkg) await store.setPackage(pkg)
automationStore.actions.fetch() automationStore.actions.fetch()
} else { } else {

View File

@ -11,6 +11,9 @@
export let screens = [] export let screens = []
$: sortedScreens = screens.sort(
(s1, s2) => s1.props._instanceName > s2.props._instanceName
)
/* /*
Using a store here seems odd.... Using a store here seems odd....
have a look in the <ComponentsHierarchyChildren /> code file to find out why. have a look in the <ComponentsHierarchyChildren /> code file to find out why.
@ -24,12 +27,15 @@
const joinPath = join("/") const joinPath = join("/")
const normalizedName = name => const normalizedName = name =>
pipe(name, [ pipe(
name,
[
trimCharsStart("./"), trimCharsStart("./"),
trimCharsStart("~/"), trimCharsStart("~/"),
trimCharsStart("../"), trimCharsStart("../"),
trimChars(" "), trimChars(" "),
]) ]
)
const changeScreen = screen => { const changeScreen = screen => {
store.setCurrentScreen(screen.props._instanceName) store.setCurrentScreen(screen.props._instanceName)
@ -38,7 +44,7 @@
</script> </script>
<div class="root"> <div class="root">
{#each screens as screen} {#each sortedScreens as screen}
<div <div
class="budibase__nav-item screen-header-row" class="budibase__nav-item screen-header-row"
class:selected={$store.currentComponentInfo._id === screen.props._id} class:selected={$store.currentComponentInfo._id === screen.props._id}

View File

@ -13,6 +13,7 @@
import { onMount } from "svelte" import { onMount } from "svelte"
export let label = "" export let label = ""
export let bindable = true
export let componentInstance = {} export let componentInstance = {}
export let control = null export let control = null
export let key = "" export let key = ""
@ -93,7 +94,7 @@
{...props} {...props}
name={key} /> name={key} />
</div> </div>
{#if control === Input && !key.startsWith('_')} {#if bindable && control === Input && !key.startsWith('_')}
<button data-cy={`${key}-binding-button`} on:click={dropdown.show}> <button data-cy={`${key}-binding-button`} on:click={dropdown.show}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>

View File

@ -88,6 +88,7 @@
{#if screenOrPageInstance} {#if screenOrPageInstance}
{#each screenOrPageDefinition as def} {#each screenOrPageDefinition as def}
<PropertyControl <PropertyControl
bindable={false}
control={def.control} control={def.control}
label={def.label} label={def.label}
key={def.key} key={def.key}

View File

@ -80,7 +80,7 @@
{#await promise} {#await promise}
<!-- This should probably be some kind of loading state? --> <!-- This should probably be some kind of loading state? -->
<div /> <div />
{:then results} {:then _}
<slot /> <slot />
{:catch error} {:catch error}
<p>Something went wrong: {error.message}</p> <p>Something went wrong: {error.message}</p>

View File

@ -3,6 +3,8 @@ export const parseAppIdFromCookie = docCookie => {
docCookie.split(";").find(c => c.trim().startsWith("budibase:token")) || docCookie.split(";").find(c => c.trim().startsWith("budibase:token")) ||
docCookie.split(";").find(c => c.trim().startsWith("builder:token")) docCookie.split(";").find(c => c.trim().startsWith("builder:token"))
if (!cookie) return location.pathname.replace(/\//g, "")
const base64Token = cookie.substring(lengthOfKey) const base64Token = cookie.substring(lengthOfKey)
const user = JSON.parse(atob(base64Token.split(".")[1])) const user = JSON.parse(atob(base64Token.split(".")[1]))

View File

@ -31,7 +31,9 @@ export const screenRouter = ({ screens, onScreenSelected, window }) => {
function route(url) { function route(url) {
const _url = makeRootedPath(url.state || url) const _url = makeRootedPath(url.state || url)
current = routes.findIndex( current = routes.findIndex(
p => p !== "*" && new RegExp("^" + p + "$").test(_url) p =>
p !== "*" &&
new RegExp("^" + p.toLowerCase() + "$").test(_url.toLowerCase())
) )
const params = {} const params = {}

View File

@ -2,6 +2,7 @@ const fs = require("fs")
const { join } = require("../../../utilities/centralPath") const { join } = require("../../../utilities/centralPath")
const AWS = require("aws-sdk") const AWS = require("aws-sdk")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const uuid = require("uuid")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir") const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
const PouchDB = require("../../../db") const PouchDB = require("../../../db")
const environment = require("../../../environment") const environment = require("../../../environment")
@ -13,7 +14,7 @@ async function invalidateCDN(cfDistribution, appId) {
.createInvalidation({ .createInvalidation({
DistributionId: cfDistribution, DistributionId: cfDistribution,
InvalidationBatch: { InvalidationBatch: {
CallerReference: appId, CallerReference: `${appId}-${uuid.v4()}`,
Paths: { Paths: {
Quantity: 1, Quantity: 1,
Items: [`/assets/${appId}/*`], Items: [`/assets/${appId}/*`],

View File

@ -11,6 +11,12 @@ const { cloneDeep } = require("lodash")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
const CALCULATION_TYPES = {
SUM: "sum",
COUNT: "count",
STATS: "stats",
}
validateJs.extend(validateJs.validators.datetime, { validateJs.extend(validateJs.validators.datetime, {
parse: function(value) { parse: function(value) {
return new Date(value).getTime() return new Date(value).getTime()
@ -137,7 +143,7 @@ exports.save = async function(ctx) {
exports.fetchView = async function(ctx) { exports.fetchView = async function(ctx) {
const instanceId = ctx.user.instanceId const instanceId = ctx.user.instanceId
const db = new CouchDB(instanceId) const db = new CouchDB(instanceId)
const { stats, group, field } = ctx.query const { calculation, group, field } = ctx.query
const viewName = ctx.params.viewName const viewName = ctx.params.viewName
// if this is a table view being looked for just transfer to that // if this is a table view being looked for just transfer to that
@ -148,22 +154,35 @@ exports.fetchView = async function(ctx) {
} }
const response = await db.query(`database/${viewName}`, { const response = await db.query(`database/${viewName}`, {
include_docs: !stats, include_docs: !calculation,
group, group,
}) })
if (stats) { if (!calculation) {
response.rows = response.rows.map(row => row.doc)
ctx.body = await linkRows.attachLinkInfo(instanceId, response.rows)
}
if (calculation === CALCULATION_TYPES.STATS) {
response.rows = response.rows.map(row => ({ response.rows = response.rows.map(row => ({
group: row.key, group: row.key,
field, field,
...row.value, ...row.value,
avg: row.value.sum / row.value.count, avg: row.value.sum / row.value.count,
})) }))
} else { ctx.body = response.rows
response.rows = response.rows.map(row => row.doc)
} }
ctx.body = await linkRows.attachLinkInfo(instanceId, response.rows) if (
calculation === CALCULATION_TYPES.COUNT ||
calculation === CALCULATION_TYPES.SUM
) {
ctx.body = response.rows.map(row => ({
group: row.key,
field,
value: row.value,
}))
}
} }
exports.fetchTableRows = async function(ctx) { exports.fetchTableRows = async function(ctx) {

View File

@ -45,7 +45,7 @@ exports[`viewBuilder Filter creates a view with multiple filters and conjunction
Object { Object {
"map": "function (doc) { "map": "function (doc) {
if (doc.tableId === \\"14f1c4e94d6a47b682ce89d35d4c78b0\\" && doc[\\"Name\\"] === \\"Test\\" || doc[\\"Yes\\"] > \\"Value\\") { if (doc.tableId === \\"14f1c4e94d6a47b682ce89d35d4c78b0\\" && doc[\\"Name\\"] === \\"Test\\" || doc[\\"Yes\\"] > \\"Value\\") {
emit(doc._id); emit(doc[\\"_id\\"], doc[\\"undefined\\"]);
} }
}", }",
"meta": Object { "meta": Object {
@ -86,6 +86,5 @@ Object {
"schema": null, "schema": null,
"tableId": "14f1c4e94d6a47b682ce89d35d4c78b0", "tableId": "14f1c4e94d6a47b682ce89d35d4c78b0",
}, },
"reduce": "_stats",
} }
`; `;

View File

@ -1,5 +1,6 @@
const TOKEN_MAP = { const TOKEN_MAP = {
EQUALS: "===", EQUALS: "===",
NOT_EQUALS: "!==",
LT: "<", LT: "<",
LTE: "<=", LTE: "<=",
MT: ">", MT: ">",
@ -22,6 +23,14 @@ const FIELD_PROPERTY = {
} }
const SCHEMA_MAP = { const SCHEMA_MAP = {
sum: {
field: "string",
value: "number",
},
count: {
field: "string",
value: "number",
},
stats: { stats: {
sum: { sum: {
type: "number", type: "number",
@ -80,8 +89,7 @@ function parseFilterExpression(filters) {
* @param {String?} groupBy - field to group calculation results on, if any * @param {String?} groupBy - field to group calculation results on, if any
*/ */
function parseEmitExpression(field, groupBy) { function parseEmitExpression(field, groupBy) {
if (field) return `emit(doc["${groupBy || "_id"}"], doc["${field}"]);` return `emit(doc["${groupBy || "_id"}"], doc["${field}"]);`
return `emit(doc._id);`
} }
/** /**
@ -101,7 +109,7 @@ function viewTemplate({ field, tableId, groupBy, filters = [], calculation }) {
const emitExpression = parseEmitExpression(field, groupBy) const emitExpression = parseEmitExpression(field, groupBy)
const reduction = field ? { reduce: "_stats" } : {} const reduction = field && calculation ? { reduce: `_${calculation}` } : {}
let schema = null let schema = null

View File

@ -18,6 +18,7 @@ describe("/views", () => {
const createView = async (config = { const createView = async (config = {
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats",
tableId: table._id tableId: table._id
}) => }) =>
await request await request
@ -65,20 +66,30 @@ describe("/views", () => {
expect(updatedTable.views).toEqual({ expect(updatedTable.views).toEqual({
TestView: { TestView: {
field: "Price", field: "Price",
calculation: "stats",
tableId: table._id, tableId: table._id,
filters: [], filters: [],
schema: { schema: {
name: { sum: {
type: "number",
},
min: {
type: "number",
},
max: {
type: "number",
},
count: {
type: "number",
},
sumsqr: {
type: "number",
},
avg: {
type: "number",
},
field: {
type: "string", type: "string",
constraints: {
type: "string"
},
},
description: {
type: "string",
constraints: {
type: "string"
},
}, },
} }
} }
@ -123,7 +134,7 @@ describe("/views", () => {
Price: 4000 Price: 4000
}) })
const res = await request const res = await request
.get(`/api/views/TestView?stats=true`) .get(`/api/views/TestView?calculation=stats`)
.set(defaultHeaders(app._id, instance._id)) .set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
@ -133,6 +144,7 @@ describe("/views", () => {
it("returns data for the created view using a group by", async () => { it("returns data for the created view using a group by", async () => {
await createView({ await createView({
calculation: "stats",
name: "TestView", name: "TestView",
field: "Price", field: "Price",
groupBy: "Category", groupBy: "Category",
@ -154,10 +166,11 @@ describe("/views", () => {
Category: "Two" Category: "Two"
}) })
const res = await request const res = await request
.get(`/api/views/TestView?stats=true&group=Category`) .get(`/api/views/TestView?calculation=stats&group=Category`)
.set(defaultHeaders(app._id, instance._id)) .set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
expect(res.body.length).toBe(2) expect(res.body.length).toBe(2)
expect(res.body).toMatchSnapshot() expect(res.body).toMatchSnapshot()
}) })

View File

@ -34,13 +34,15 @@ module.exports = async (ctx, next) => {
let appId = process.env.CLOUD ? ctx.subdomains[1] : ctx.params.appId let appId = process.env.CLOUD ? ctx.subdomains[1] : ctx.params.appId
if (!appId) { // if appId can't be determined from path param or subdomain
appId = ctx.referer && ctx.referer.split("/").pop() if (!appId && ctx.request.headers.referer) {
const url = new URL(ctx.request.headers.referer)
// remove leading and trailing slashes from appId
appId = url.pathname.replace(/\//g, "")
} }
ctx.user = { ctx.user = {
// if appId can't be determined from path param or subdomain appId,
appId: appId,
} }
await next() await next()
return return

View File

@ -12,7 +12,12 @@
import AgGrid from "@budibase/svelte-ag-grid" import AgGrid from "@budibase/svelte-ag-grid"
import CreateRowButton from "./CreateRow/Button.svelte" import CreateRowButton from "./CreateRow/Button.svelte"
import { TextButton as DeleteButton, Icon, Modal, ModalContent } from "@budibase/bbui" import {
TextButton as DeleteButton,
Icon,
Modal,
ModalContent,
} from "@budibase/bbui"
export let _bb export let _bb
export let datasource = {} export let datasource = {}
@ -26,7 +31,7 @@
let canEdit = editable && datasource && datasource.type !== "view" let canEdit = editable && datasource && datasource.type !== "view"
let canAddDelete = editable && datasource && datasource.type === "table" let canAddDelete = editable && datasource && datasource.type === "table"
let modal; let modal
let store = _bb.store let store = _bb.store
let dataLoaded = false let dataLoaded = false
@ -167,7 +172,10 @@
on:select={({ detail }) => (selectedRows = detail)} /> on:select={({ detail }) => (selectedRows = detail)} />
{/if} {/if}
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent title="Confirm Row Deletion" confirmText="Delete" onConfirm={deleteRows} > <ModalContent
title="Confirm Row Deletion"
confirmText="Delete"
onConfirm={deleteRows}>
<span>Are you sure you want to delete {selectedRows.length} row(s)?</span> <span>Are you sure you want to delete {selectedRows.length} row(s)?</span>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@ -35,7 +35,7 @@
{#await _appPromise} {#await _appPromise}
loading loading
{:then _bb} {:then _}
<div id="current_component" bind:this={currentComponent} /> <div id="current_component" bind:this={currentComponent} />
{/await} {/await}

View File

@ -1,6 +1,6 @@
<script> <script>
import { Modal, ModalContent, Icon } from '@budibase/bbui' import { Modal, ModalContent, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
import { FILE_TYPES } from "./fileTypes" import { FILE_TYPES } from "./fileTypes"
@ -9,16 +9,16 @@
export let height = "70" export let height = "70"
export let width = "70" export let width = "70"
let modal; let modal
let currentFile; let currentFile
const openModal = (file) => { const openModal = file => {
currentFile = file currentFile = file
modal.show() modal.show()
} }
const handleConfirm = () => { const handleConfirm = () => {
dispatch('delete', currentFile) dispatch("delete", currentFile)
} }
</script> </script>
@ -31,12 +31,19 @@
{:else}<i class="far fa-file" />{/if} {:else}<i class="far fa-file" />{/if}
</a> </a>
<span>{file.name}</span> <span>{file.name}</span>
<div class="button-placement"><button primary on:click|stopPropagation={() => openModal(file)}>×</button></div> <div class="button-placement">
<button primary on:click|stopPropagation={() => openModal(file)}>
×
</button>
</div>
</div> </div>
{/each} {/each}
</div> </div>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent title="Confirm File Deletion" confirmText="Delete" onConfirm={handleConfirm} > <ModalContent
title="Confirm File Deletion"
confirmText="Delete"
onConfirm={handleConfirm}>
<span>Are you sure you want to delete this attachment?</span> <span>Are you sure you want to delete this attachment?</span>
</ModalContent> </ModalContent>
</Modal> </Modal>